1use std::fs::File;
29use std::io::{Read, Seek, SeekFrom};
30use std::ops::{Bound, Range};
31
32use crate::format::{
33 self, BranchId, FormatLimits, FrameKind, OwnedStoredRecord, StoredRecord, StreamId,
34};
35use crate::{Result, SalamanderError};
36
37use super::index::{PostingEntry, Sidecar, SEEK_POINT_SPACING};
38use super::segment::parse_batch_control;
39
40const READ_CHUNK: usize = 128 * 1024;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47pub enum ReplayEnd {
48 #[default]
51 Head,
52 At(u64),
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Default)]
61pub enum StreamSelector {
62 #[default]
64 All,
65 Streams(Vec<StreamId>),
67 PartitionClass {
72 count: u32,
74 index: u32,
76 },
77}
78
79impl StreamSelector {
80 pub(crate) fn validate(&self) -> Result<()> {
81 if let StreamSelector::PartitionClass { count, index } = self {
82 if *count == 0 || index >= count {
83 return Err(SalamanderError::InvalidArgument(format!(
84 "partition class {index} of {count} is not a valid partition"
85 )));
86 }
87 }
88 Ok(())
89 }
90
91 pub(crate) fn matches(&self, stream: StreamId) -> bool {
92 match self {
93 StreamSelector::All => true,
94 StreamSelector::Streams(ids) => ids.binary_search(&stream).is_ok(),
95 StreamSelector::PartitionClass { count, index } => {
96 partition_of(stream, *count) == *index
97 }
98 }
99 }
100
101 pub(crate) fn disjoint_from(&self, postings: &[PostingEntry]) -> bool {
107 match self {
108 StreamSelector::All => false,
109 StreamSelector::Streams(ids) => !ids
110 .iter()
111 .any(|id| postings.binary_search_by_key(id, |p| p.stream).is_ok()),
112 StreamSelector::PartitionClass { count, index } => !postings
113 .iter()
114 .any(|p| partition_of(p.stream, *count) == *index),
115 }
116 }
117}
118
119pub fn partition_of(stream: StreamId, count: u32) -> u32 {
124 let head = u64::from_le_bytes(stream.as_bytes()[..8].try_into().unwrap());
125 (head % u64::from(count)) as u32
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
130pub enum VerificationMode {
131 #[default]
134 FrameCrc,
135 BatchDigests,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct ReplayPlan {
145 pub branch: BranchId,
147 pub streams: StreamSelector,
149 pub from: Bound<u64>,
151 pub until: ReplayEnd,
153 pub time: Option<Range<i64>>,
157 pub max_events: Option<u64>,
159 pub verification: VerificationMode,
161}
162
163impl Default for ReplayPlan {
164 fn default() -> Self {
165 ReplayPlan {
166 branch: BranchId::ZERO,
167 streams: StreamSelector::All,
168 from: Bound::Unbounded,
169 until: ReplayEnd::Head,
170 time: None,
171 max_events: None,
172 verification: VerificationMode::FrameCrc,
173 }
174 }
175}
176
177pub trait RecordReader {
181 fn next(&mut self) -> Result<Option<StoredRecord<'_>>>;
184
185 fn continuation(&self) -> u64;
189
190 fn next_owned(&mut self) -> Result<Option<OwnedStoredRecord>> {
193 Ok(self.next()?.map(OwnedStoredRecord::from))
194 }
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub(crate) enum FrameFilter {
201 UserEvents,
202 SystemOnly,
203}
204
205#[derive(Debug, Clone)]
209pub(crate) struct ResolvedFilter {
210 pub from: u64,
211 pub until: u64,
212 pub selector: StreamSelector,
213 pub scopes: Option<Vec<(BranchId, u64)>>,
217 pub time: Option<Range<i64>>,
218 pub kinds: FrameFilter,
219 pub max_events: Option<u64>,
220 pub verification: VerificationMode,
221}
222
223impl ResolvedFilter {
224 pub(crate) fn raw(from: u64, until: u64, kinds: FrameFilter) -> Self {
225 ResolvedFilter {
226 from,
227 until,
228 selector: StreamSelector::All,
229 scopes: None,
230 time: None,
231 kinds,
232 max_events: None,
233 verification: VerificationMode::FrameCrc,
234 }
235 }
236}
237
238#[derive(Debug)]
240pub(crate) struct PlannedSegment {
241 pub base: u64,
242 pub end: u64,
245 pub source: SegmentSource,
246}
247
248#[derive(Debug)]
249pub(crate) enum SegmentSource {
250 Closed(std::path::PathBuf),
251 Active,
254}
255
256pub(crate) fn intersecting_range(
262 bases: &[u64],
263 overall_end: u64,
264 from: u64,
265 until: u64,
266) -> Range<usize> {
267 if bases.is_empty() || from >= overall_end.min(until) {
268 return 0..0;
269 }
270 let start = bases.partition_point(|&b| b <= from).saturating_sub(1);
274 let stop = bases.partition_point(|&b| b < until);
276 start..stop.max(start)
277}
278
279struct SegmentCursor {
280 file: File,
281 buf: Vec<u8>,
282 start: usize,
284 valid: usize,
286 eof: bool,
287}
288
289impl SegmentCursor {
290 fn new(mut file: File, seek_byte: u64) -> Result<Self> {
291 file.seek(SeekFrom::Start(seek_byte))?;
292 Ok(SegmentCursor {
293 file,
294 buf: Vec::new(),
295 start: 0,
296 valid: 0,
297 eof: false,
298 })
299 }
300
301 fn available(&self) -> &[u8] {
302 &self.buf[self.start..self.valid]
303 }
304
305 fn fill(&mut self, need: usize) -> Result<()> {
309 while self.valid - self.start < need && !self.eof {
310 if self.start > 0 {
311 self.buf.copy_within(self.start..self.valid, 0);
312 self.valid -= self.start;
313 self.start = 0;
314 }
315 let target = (self.valid + READ_CHUNK).max(need);
316 if self.buf.len() < target {
317 self.buf.resize(target, 0);
318 }
319 let read = self.file.read(&mut self.buf[self.valid..])?;
320 if read == 0 {
321 self.eof = true;
322 }
323 self.valid += read;
324 }
325 Ok(())
326 }
327
328 fn consume(&mut self, n: usize) {
329 debug_assert!(self.start + n <= self.valid);
330 self.start += n;
331 }
332}
333
334struct BatchTrack {
336 first: u64,
337 batch_id: crate::format::BatchId,
338 seen: u32,
339 check: Option<(u32, u32, u32)>, }
342
343pub struct LogReader<'log> {
345 log: &'log super::Log,
346 limits: FormatLimits,
347 filter: ResolvedFilter,
348 segments: Vec<PlannedSegment>,
349 seg_idx: usize,
350 cursor: Option<SegmentCursor>,
351 expected: u64,
353 batch: Option<BatchTrack>,
354 yielded: u64,
355 continuation: u64,
356 finished: bool,
357 max_buf: usize,
358}
359
360struct Located {
363 kind: FrameKind,
364 flags: u8,
365 position: u64,
366 envelope: crate::format::RecordEnvelopeV2,
367 payload_start: usize,
368 payload_len: usize,
369}
370
371impl<'log> LogReader<'log> {
372 pub(crate) fn new(
373 log: &'log super::Log,
374 filter: ResolvedFilter,
375 segments: Vec<PlannedSegment>,
376 ) -> Self {
377 let continuation = filter.from;
378 LogReader {
379 log,
380 limits: FormatLimits::default(),
381 filter,
382 segments,
383 seg_idx: 0,
384 cursor: None,
385 expected: 0,
386 batch: None,
387 yielded: 0,
388 continuation,
389 finished: false,
390 max_buf: 0,
391 }
392 }
393
394 pub fn max_buffer_bytes(&self) -> usize {
397 self.max_buf
398 }
399
400 fn enter_next_segment(&mut self) -> Result<bool> {
404 while self.seg_idx < self.segments.len() {
405 let seg = &self.segments[self.seg_idx];
406 let (base, end) = (seg.base, seg.end);
407 let mut seek = (base, 0u64);
408 match &seg.source {
409 SegmentSource::Closed(path) => {
410 let sidecar = self.log.sidecar_for(path, base, end);
411 if let Some(sidecar) = sidecar {
412 if self.skippable(&sidecar) {
413 self.seg_idx += 1;
414 continue;
415 }
416 if self.filter.from > base {
417 if let Some(point) = sidecar.seek_point_before(self.filter.from) {
418 seek = point;
419 }
420 }
421 }
422 let file = File::open(path)?;
423 self.cursor = Some(SegmentCursor::new(file, seek.1)?);
424 }
425 SegmentSource::Active => {
426 let file = self.log.active_handle()?;
427 self.cursor = Some(SegmentCursor::new(file, 0)?);
428 }
429 }
430 self.expected = seek.0;
431 self.batch = None;
432 self.seg_idx += 1;
433 return Ok(true);
434 }
435 Ok(false)
436 }
437
438 fn skippable(&self, sidecar: &Sidecar) -> bool {
442 match self.filter.kinds {
443 FrameFilter::SystemOnly => sidecar.system_frames == 0,
444 FrameFilter::UserEvents => {
445 self.filter.selector.disjoint_from(&sidecar.postings)
446 || self
447 .filter
448 .time
449 .as_ref()
450 .is_some_and(|range| sidecar.time_disjoint(range))
451 }
452 }
453 }
454
455 fn advance_to_match(&mut self) -> Result<Option<Located>> {
458 'segments: loop {
459 if self.finished {
460 return Ok(None);
461 }
462 if self
463 .filter
464 .max_events
465 .is_some_and(|max| self.yielded >= max)
466 {
467 self.finished = true;
468 return Ok(None);
469 }
470 if self.cursor.is_none() && !self.enter_next_segment()? {
471 self.finished = true;
472 self.continuation = self
473 .continuation
474 .max(self.filter.until.min(self.log.head()));
475 return Ok(None);
476 }
477 let closed = !matches!(
478 self.segments[self.seg_idx - 1].source,
479 SegmentSource::Active
480 );
481 let seg_end = self.segments[self.seg_idx - 1].end;
482
483 loop {
484 let cursor = self.cursor.as_mut().expect("cursor set above");
485 cursor.fill(format::FRAME_HEADER_LEN)?;
486 self.max_buf = self.max_buf.max(cursor.buf.len());
487 if cursor.available().len() < format::FRAME_HEADER_LEN {
488 let leftover = cursor.available().len();
489 if leftover != 0 && closed {
490 return Err(SalamanderError::Corrupt {
491 offset: self.expected,
492 reason: format!("closed segment has {leftover} trailing byte(s)"),
493 });
494 }
495 if closed && self.expected < seg_end {
498 return Err(SalamanderError::Corrupt {
499 offset: self.expected,
500 reason: format!(
501 "closed segment ended at position {} before its recorded end {seg_end}",
502 self.expected
503 ),
504 });
505 }
506 self.cursor = None;
507 continue 'segments;
508 }
509 let total = match format::frame_total_len(cursor.available(), self.limits)? {
510 Some(total) => total,
511 None => unreachable!("header length ensured by fill"),
512 };
513 cursor.fill(total)?;
514 self.max_buf = self.max_buf.max(cursor.buf.len());
515 if cursor.available().len() < total {
516 if closed {
517 return Err(SalamanderError::Corrupt {
518 offset: self.expected,
519 reason: "closed segment ends mid-frame".into(),
520 });
521 }
522 self.cursor = None;
523 continue 'segments;
524 }
525
526 let frame_start = cursor.start;
527 let (record, consumed) = format::decode(cursor.available(), self.limits)?
528 .expect("full frame ensured by fill");
529 debug_assert_eq!(consumed, total);
530 let kind = record.kind;
531 let flags = record.flags;
532 let position = record.position;
533 let payload_len = record.payload.len();
534 let envelope = record.envelope;
535 let payload_start = frame_start + total - payload_len;
536
537 match kind {
540 FrameKind::BatchBegin => {
541 if self.batch.is_some() || position != self.expected {
542 return Err(corrupt_sequence(position, self.expected));
543 }
544 let check = if self.filter.verification == VerificationMode::BatchDigests {
545 let payload = &cursor.buf[payload_start..payload_start + payload_len];
546 let (count, digest) = parse_batch_control(payload)?;
547 Some((count, digest, 0u32))
548 } else {
549 None
550 };
551 self.batch = Some(BatchTrack {
552 first: position,
553 batch_id: envelope.batch_id,
554 seen: 0,
555 check,
556 });
557 cursor.consume(total);
558 continue;
559 }
560 FrameKind::BatchCommit => {
561 let Some(batch) = self.batch.take() else {
562 return Err(SalamanderError::Corrupt {
563 offset: position,
564 reason: "batch commit without begin".into(),
565 });
566 };
567 if position != batch.first || envelope.batch_id != batch.batch_id {
568 return Err(SalamanderError::Corrupt {
569 offset: position,
570 reason: "batch commit does not match its begin".into(),
571 });
572 }
573 if let Some((count, digest, running)) = batch.check {
574 let payload = &cursor.buf[payload_start..payload_start + payload_len];
575 let (commit_count, commit_digest) = parse_batch_control(payload)?;
576 if commit_count != count
577 || commit_digest != digest
578 || batch.seen != count
579 || running != digest
580 {
581 return Err(SalamanderError::Corrupt {
582 offset: position,
583 reason: "batch digest verification failed".into(),
584 });
585 }
586 }
587 if batch.seen == 0 {
588 return Err(SalamanderError::Corrupt {
589 offset: position,
590 reason: "batch committed zero events".into(),
591 });
592 }
593 self.expected = batch.first + u64::from(batch.seen);
594 cursor.consume(total);
595 continue;
596 }
597 FrameKind::System => {
598 if self.batch.is_some() || position != self.expected {
599 return Err(corrupt_sequence(position, self.expected));
600 }
601 }
602 FrameKind::Event => match self.batch.as_mut() {
603 Some(batch) => {
604 let slot = batch.first + u64::from(batch.seen);
605 if position != slot
606 || envelope.batch_id != batch.batch_id
607 || envelope.batch_index != batch.seen
608 {
609 return Err(corrupt_sequence(position, slot));
610 }
611 if let Some((_, _, running)) = batch.check.as_mut() {
612 *running = crc32c::crc32c_append(
613 *running,
614 &cursor.buf[frame_start..frame_start + total],
615 );
616 }
617 batch.seen += 1;
618 }
619 None => {
620 if position != self.expected {
621 return Err(corrupt_sequence(position, self.expected));
622 }
623 self.expected += 1;
624 }
625 },
626 }
627
628 let is_event = kind == FrameKind::Event;
630 if is_event && position >= self.filter.until {
631 self.finished = true;
632 self.continuation = self.continuation.max(self.filter.until);
633 return Ok(None);
634 }
635 let wanted = match self.filter.kinds {
636 FrameFilter::UserEvents => is_event,
637 FrameFilter::SystemOnly => kind == FrameKind::System,
638 };
639 let passes = wanted
640 && (!is_event || position >= self.filter.from)
641 && self.filter.selector.matches(envelope.stream_id)
642 && self.filter.scopes.as_ref().is_none_or(|scopes| {
643 scopes.iter().any(|(branch, upper)| {
644 envelope.branch_id == *branch && position < *upper
645 })
646 })
647 && self
648 .filter
649 .time
650 .as_ref()
651 .is_none_or(|range| range.contains(&envelope.timestamp_unix_nanos));
652
653 if is_event {
654 self.continuation = self.continuation.max(position + 1);
655 }
656 cursor.consume(total);
657 if passes {
658 self.yielded += 1;
659 return Ok(Some(Located {
660 kind,
661 flags,
662 position,
663 envelope,
664 payload_start,
665 payload_len,
666 }));
667 }
668 }
669 }
670 }
671}
672
673impl std::fmt::Debug for LogReader<'_> {
674 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
675 f.debug_struct("LogReader")
676 .field("from", &self.filter.from)
677 .field("until", &self.filter.until)
678 .field("selector", &self.filter.selector)
679 .field("segments", &self.segments.len())
680 .field("continuation", &self.continuation)
681 .field("finished", &self.finished)
682 .finish_non_exhaustive()
683 }
684}
685
686fn corrupt_sequence(position: u64, expected: u64) -> SalamanderError {
687 SalamanderError::Corrupt {
688 offset: position,
689 reason: format!("position {position} breaks expected sequence {expected}"),
690 }
691}
692
693impl RecordReader for LogReader<'_> {
694 fn next(&mut self) -> Result<Option<StoredRecord<'_>>> {
695 let located = match self.advance_to_match()? {
696 Some(located) => located,
697 None => return Ok(None),
698 };
699 let cursor = self.cursor.as_ref().expect("cursor holds matched frame");
700 let payload =
701 &cursor.buf[located.payload_start..located.payload_start + located.payload_len];
702 Ok(Some(StoredRecord {
703 kind: located.kind,
704 flags: located.flags,
705 position: located.position,
706 envelope: located.envelope,
707 payload,
708 }))
709 }
710
711 fn continuation(&self) -> u64 {
712 self.continuation
713 }
714}
715
716pub(crate) fn build_sidecar(path: &std::path::Path, base: u64) -> Result<Sidecar> {
721 let file = File::open(path)?;
722 let limits = FormatLimits::default();
723 let mut cursor = SegmentCursor::new(file, 0)?;
724 let mut expected = base;
725 let mut in_batch: Option<(u64, u32)> = None; let mut byte_offset = 0u64;
727 let mut min_ts = i64::MAX;
728 let mut max_ts = i64::MIN;
729 let mut system_frames = 0u64;
730 let mut seek_points = vec![(base, 0u64)];
731 let mut last_seek_byte = 0u64;
732 let mut postings: std::collections::BTreeMap<StreamId, PostingEntry> =
733 std::collections::BTreeMap::new();
734
735 loop {
736 cursor.fill(format::FRAME_HEADER_LEN)?;
737 if cursor.available().len() < format::FRAME_HEADER_LEN {
738 if !cursor.available().is_empty() {
739 return Err(SalamanderError::Corrupt {
740 offset: expected,
741 reason: "segment has trailing bytes".into(),
742 });
743 }
744 break;
745 }
746 let total =
747 format::frame_total_len(cursor.available(), limits)?.expect("header ensured by fill");
748 cursor.fill(total)?;
749 if cursor.available().len() < total {
750 return Err(SalamanderError::Corrupt {
751 offset: expected,
752 reason: "segment ends mid-frame".into(),
753 });
754 }
755 if in_batch.is_none()
756 && byte_offset > 0
757 && byte_offset - last_seek_byte >= SEEK_POINT_SPACING
758 {
759 seek_points.push((expected, byte_offset));
760 last_seek_byte = byte_offset;
761 }
762 let (record, consumed) =
763 format::decode(cursor.available(), limits)?.expect("full frame ensured by fill");
764 match record.kind {
765 FrameKind::BatchBegin => {
766 if in_batch.is_some() || record.position != expected {
767 return Err(corrupt_sequence(record.position, expected));
768 }
769 in_batch = Some((record.position, 0));
770 }
771 FrameKind::BatchCommit => {
772 let Some((first, seen)) = in_batch.take() else {
773 return Err(SalamanderError::Corrupt {
774 offset: record.position,
775 reason: "batch commit without begin".into(),
776 });
777 };
778 if record.position != first || seen == 0 {
779 return Err(corrupt_sequence(record.position, first));
780 }
781 expected = first + u64::from(seen);
782 }
783 FrameKind::System => {
784 if in_batch.is_some() || record.position != expected {
785 return Err(corrupt_sequence(record.position, expected));
786 }
787 system_frames += 1;
788 }
789 FrameKind::Event => {
790 let slot = match in_batch.as_mut() {
791 Some((first, seen)) => {
792 let slot = *first + u64::from(*seen);
793 *seen += 1;
794 slot
795 }
796 None => {
797 let slot = expected;
798 expected += 1;
799 slot
800 }
801 };
802 if record.position != slot {
803 return Err(corrupt_sequence(record.position, slot));
804 }
805 min_ts = min_ts.min(record.envelope.timestamp_unix_nanos);
806 max_ts = max_ts.max(record.envelope.timestamp_unix_nanos);
807 let entry = postings
808 .entry(record.envelope.stream_id)
809 .or_insert(PostingEntry {
810 stream: record.envelope.stream_id,
811 first: record.position,
812 last: record.position,
813 count: 0,
814 });
815 entry.last = record.position;
816 entry.count += 1;
817 }
818 }
819 cursor.consume(consumed);
820 byte_offset += consumed as u64;
821 }
822 if in_batch.is_some() {
823 return Err(SalamanderError::Corrupt {
824 offset: expected,
825 reason: "segment ends inside an uncommitted batch".into(),
826 });
827 }
828 Ok(Sidecar {
829 base,
830 end: expected,
831 min_ts,
832 max_ts,
833 system_frames,
834 seek_points,
835 postings: postings.into_values().collect(),
836 })
837}
838
839#[cfg(test)]
840mod tests {
841 use super::*;
842
843 #[test]
844 fn partition_of_is_stable_and_in_range() {
845 let a = StreamId::from_bytes([1, 0, 0, 0, 0, 0, 0, 0, 9, 9, 9, 9, 9, 9, 9, 9]);
848 assert_eq!(partition_of(a, 4), 1);
849 let b = StreamId::from_bytes([7, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
850 assert_eq!(partition_of(b, 4), (263u64 % 4) as u32);
851 for count in [1u32, 2, 3, 16, 1024] {
852 assert!(partition_of(a, count) < count);
853 }
854 }
855
856 #[test]
857 fn selector_validation_rejects_bad_partitions() {
858 assert!(StreamSelector::PartitionClass { count: 0, index: 0 }
859 .validate()
860 .is_err());
861 assert!(StreamSelector::PartitionClass { count: 4, index: 4 }
862 .validate()
863 .is_err());
864 assert!(StreamSelector::PartitionClass { count: 4, index: 3 }
865 .validate()
866 .is_ok());
867 }
868
869 #[test]
870 fn disjoint_from_proves_skips_for_all_selector_shapes() {
871 let posted = |bytes: [u8; 16]| PostingEntry {
872 stream: StreamId::from_bytes(bytes),
873 first: 0,
874 last: 0,
875 count: 1,
876 };
877 let mut postings = vec![posted([4; 16]), posted([8; 16])];
878 postings.sort_by_key(|p| p.stream);
879
880 assert!(!StreamSelector::All.disjoint_from(&postings));
881
882 let hit = StreamSelector::Streams(vec![StreamId::from_bytes([4; 16])]);
883 let miss = StreamSelector::Streams(vec![StreamId::from_bytes([5; 16])]);
884 assert!(!hit.disjoint_from(&postings));
885 assert!(miss.disjoint_from(&postings));
886
887 let classes: Vec<u32> = postings.iter().map(|p| partition_of(p.stream, 8)).collect();
890 for index in 0..8u32 {
891 let selector = StreamSelector::PartitionClass { count: 8, index };
892 assert_eq!(
893 selector.disjoint_from(&postings),
894 !classes.contains(&index),
895 "class {index}"
896 );
897 }
898 }
899
900 #[test]
901 fn intersecting_range_prunes_synthetic_hundred_million_records() {
902 let bases: Vec<u64> = (0..10_000u64).map(|i| i * 10_000).collect();
906 let overall_end = 100_000_000u64;
907
908 let range = intersecting_range(&bases, overall_end, 55_554_321, 55_554_322);
909 assert_eq!(range, 5_555..5_556);
910
911 let range = intersecting_range(&bases, overall_end, 0, overall_end);
912 assert_eq!(range, 0..10_000);
913
914 let range = intersecting_range(&bases, overall_end, 99_999_999, u64::MAX);
915 assert_eq!(range, 9_999..10_000);
916
917 assert_eq!(
918 intersecting_range(&bases, overall_end, overall_end, u64::MAX),
919 0..0
920 );
921 assert_eq!(intersecting_range(&bases, overall_end, 5, 5), 0..0);
922 }
923}