Skip to main content

salamander/log/
reader.rs

1//! WP-04 — the bounded-memory streaming reader.
2//!
3//! Replaces the materializing scans: a [`LogReader`] walks closed segments
4//! and a snapshot handle of the active segment through a fixed-size refill
5//! buffer, decoding one frame at a time. Peak memory is bounded by the
6//! buffer (one read chunk plus the largest single frame), never by result
7//! count or segment size.
8//!
9//! Selection happens at three altitudes, cheapest first:
10//! 1. **segment pruning** — binary search over `[base, end)` ranges picks
11//!    only segments intersecting the plan's position window; sidecar
12//!    postings/timestamps prove whole segments irrelevant to a stream or
13//!    time selector without opening them (the WP-09 skip guarantee);
14//! 2. **in-segment seek** — sparse sidecar seek points land the cursor
15//!    near `from` instead of at byte 0;
16//! 3. **per-frame filters** — position window, frame kind, stream
17//!    selector, branch scopes, and time bounds are applied after envelope
18//!    decode; unselected payloads are never copied out of the buffer, and
19//!    payload bytes are never interpreted (INV-9).
20//!
21//! Every traversed frame is CRC-verified (that is `format::decode`'s
22//! contract); [`VerificationMode::BatchDigests`] additionally re-verifies
23//! batch begin/commit digests while streaming. Digest verification detects
24//! damage and fails the read at the commit frame — it does not buffer
25//! whole batches to suppress already-yielded events, which would break the
26//! memory bound (recorded as a WP-04 design decision).
27
28use 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
40/// Refill granularity for the segment cursor. The buffer holds at most one
41/// chunk of read-ahead plus the largest single frame it has ever needed.
42const READ_CHUNK: usize = 128 * 1024;
43
44/// Where a replay stops (exclusive), resolved against the log head when
45/// the reader is constructed.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47pub enum ReplayEnd {
48    /// The head observed at reader construction; later appends are not
49    /// yielded by this reader.
50    #[default]
51    Head,
52    /// A fixed exclusive position. Beyond-head values are rejected at
53    /// construction with `OffsetBeyondHead`.
54    At(u64),
55}
56
57/// Which streams a plan selects. Evaluated per record from the envelope's
58/// `StreamId` — never from the stream catalog — so streams created after
59/// any derived state was built still route correctly.
60#[derive(Debug, Clone, PartialEq, Eq, Default)]
61pub enum StreamSelector {
62    /// Every stream.
63    #[default]
64    All,
65    /// An explicit set of stream IDs (deduplicated and sorted internally).
66    Streams(Vec<StreamId>),
67    /// Every stream whose ID hashes into partition `index` of `count`
68    /// equal hash classes — the healing unit. The hash is the first eight
69    /// little-endian bytes of the `StreamId` modulo `count`, which is
70    /// uniform because stream IDs are themselves hash-derived.
71    PartitionClass {
72        /// Number of partition classes.
73        count: u32,
74        /// Which class (0-based) to select.
75        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    /// True when the postings prove **no** stream in a segment matches —
102    /// the segment-skip proof required by WP-04 for WP-09. `All` never
103    /// skips; an explicit set probes each ID by binary search; a partition
104    /// class tests every posted stream (postings are per distinct stream,
105    /// so this is small).
106    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
119/// The stable partition-routing hash: first eight little-endian bytes of
120/// the stream ID modulo the partition count. WP-09 versions this scheme;
121/// changing it invalidates partitioned derived state, so it must never be
122/// silently altered.
123pub 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/// How much re-verification the reader performs beyond per-frame CRCs.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
130pub enum VerificationMode {
131    /// Every traversed frame's CRC is checked (always on). Batch framing
132    /// is tracked for position continuity only.
133    #[default]
134    FrameCrc,
135    /// Additionally re-verify batch begin/commit control digests over the
136    /// raw event-frame bytes, streaming (no batch buffering). A mismatch
137    /// fails the read with `Corrupt` at the commit frame.
138    BatchDigests,
139}
140
141/// A declarative replay request (spec/04). Resolved against the log and
142/// branch catalog by `Salamander::read`.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct ReplayPlan {
145    /// Branch whose (inherited) history to read.
146    pub branch: BranchId,
147    /// Which streams to include.
148    pub streams: StreamSelector,
149    /// Lower bound on global position.
150    pub from: Bound<u64>,
151    /// Where to stop (exclusive).
152    pub until: ReplayEnd,
153    /// Half-open envelope-timestamp filter (unix nanos). Timestamps are
154    /// not monotonic in the log, so this is an exact per-record *filter*;
155    /// sidecar min/max ranges are used only as a segment-skip hint.
156    pub time: Option<Range<i64>>,
157    /// Stop after yielding this many events, if set.
158    pub max_events: Option<u64>,
159    /// How much integrity re-verification to perform while reading.
160    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
177/// The object-safe pull interface over any record reader. `next` lends a
178/// record borrowing the reader's internal buffer; callers that need to
179/// hold records across calls use `next_owned`.
180pub trait RecordReader {
181    /// Yields the next matching record, borrowing the reader's internal
182    /// buffer, or `None` at the end of the plan.
183    fn next(&mut self) -> Result<Option<StoredRecord<'_>>>;
184
185    /// The resumable continuation: the first position this reader has not
186    /// yet yielded or skipped past. Feeding it back as `from` in a new
187    /// plan resumes without gaps or duplicates.
188    fn continuation(&self) -> u64;
189
190    /// Like [`next`](Self::next) but returns an owned record, copying the
191    /// payload out of the buffer so it can be held across calls.
192    fn next_owned(&mut self) -> Result<Option<OwnedStoredRecord>> {
193        Ok(self.next()?.map(OwnedStoredRecord::from))
194    }
195}
196
197/// Which frame kinds a reader yields. User replay yields `Event` frames;
198/// catalog rebuilds read `System` frames.
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub(crate) enum FrameFilter {
201    UserEvents,
202    SystemOnly,
203}
204
205/// A plan resolved against a concrete log: position window fixed, branch
206/// scopes flattened. Everything the log layer filters on is engine
207/// envelope data — payload bytes stay opaque (INV-9).
208#[derive(Debug, Clone)]
209pub(crate) struct ResolvedFilter {
210    pub from: u64,
211    pub until: u64,
212    pub selector: StreamSelector,
213    /// Flattened branch visibility: `(branch, exclusive upper bound)`
214    /// pairs from `BranchCatalog::replay_scopes`. `None` disables branch
215    /// filtering (raw log-level reads).
216    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/// One segment the planner selected, in log order.
239#[derive(Debug)]
240pub(crate) struct PlannedSegment {
241    pub base: u64,
242    /// Exclusive position bound; the next segment's base. Derived from the
243    /// manifest layout, not from any sidecar.
244    pub end: u64,
245    pub source: SegmentSource,
246}
247
248#[derive(Debug)]
249pub(crate) enum SegmentSource {
250    Closed(std::path::PathBuf),
251    /// The active segment, read through a cloned handle taken when the
252    /// reader enters it.
253    Active,
254}
255
256/// Prune `[base, end)` segment ranges to those intersecting
257/// `[from, until)`. Pure index arithmetic over metadata — O(log n) binary
258/// search plus the intersecting range — so planning cost is independent of
259/// record counts (the 100M-synthetic-records planning test drives this
260/// directly).
261pub(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    // Segments are contiguous: segment i spans [bases[i], bases[i+1]) with
271    // the last ending at overall_end. A position `from` below overall_end
272    // therefore lives in the segment with the greatest base <= from.
273    let start = bases.partition_point(|&b| b <= from).saturating_sub(1);
274    // First segment starting at or past `until` is the exclusive stop.
275    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    /// First unconsumed byte in `buf`.
283    start: usize,
284    /// Bytes of `buf` holding file content.
285    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    /// Ensure at least `need` unconsumed bytes are buffered or EOF has
306    /// been reached. Compacts before growing so the buffer stays bounded
307    /// by one chunk of read-ahead plus the largest single frame.
308    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
334/// Streaming batch-integrity state (see `VerificationMode`).
335struct BatchTrack {
336    first: u64,
337    batch_id: crate::format::BatchId,
338    seen: u32,
339    /// Populated only in `BatchDigests` mode.
340    check: Option<(u32, u32, u32)>, // (count, expected digest, running crc)
341}
342
343/// The concrete bounded-memory reader over one log.
344pub 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    /// Continuity: the position the next non-batch frame must carry.
352    expected: u64,
353    batch: Option<BatchTrack>,
354    yielded: u64,
355    continuation: u64,
356    finished: bool,
357    max_buf: usize,
358}
359
360/// A matched frame located in the cursor buffer: everything `next` needs
361/// to assemble a `StoredRecord` without re-borrowing during the search.
362struct 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    /// Largest cursor buffer observed, for the peak-memory tests. This is
395    /// the reader's entire record-dependent allocation.
396    pub fn max_buffer_bytes(&self) -> usize {
397        self.max_buf
398    }
399
400    /// Enter the next planned segment, consulting its sidecar to skip it
401    /// outright or to seek within it. Returns false when no segments
402    /// remain.
403    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    /// Whole-segment skip proof from derived metadata alone: no selected
439    /// stream, no system frames for a system read, or a provably disjoint
440    /// timestamp range. Never consults payload bytes.
441    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    /// Walk frames until one passes every filter; maintain continuity and
456    /// batch state along the way. `Ok(None)` is exhaustion.
457    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                    // Active tail: bytes past the last complete frame are
496                    // an in-flight append; recovery would truncate them.
497                    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                // --- continuity + batch machine (mirrors recovery's
538                // scan_records; any deviation is damage, not a tail) ---
539                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                // --- filters ---
629                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
716/// Build a segment's sidecar by walking it once with the same bounded
717/// cursor the reader uses (envelope decode only, no payload copies). Any
718/// integrity failure aborts the build — a damaged segment gets no sidecar
719/// and surfaces its damage on the next scan that traverses it.
720pub(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; // (first, seen)
726    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        // Pinned values: WP-09 derived state depends on this hash never
846        // changing (spec/09 partition scheme versioning).
847        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        // Partition classes: both posted streams land in a computable
888        // class; the other classes must be provably disjoint.
889        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        // 10_000 segments of 10_000 records each: 100M records of pure
903        // metadata. Planning must be index arithmetic — no per-record or
904        // per-segment I/O — and must pick exactly the right window.
905        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}