Skip to main content

krabka_log/log/
read.rs

1//! The read paths: decoded batches, verbatim wire bytes, and file-region
2//! descriptors for the zero-copy `sendfile` fetch.
3//!
4//! All three find the first segment that can hold the fetch offset by a
5//! binary search over the sealed segments, then walk on from it through the
6//! later sealed segments and the active one, so a read can span a segment
7//! boundary. All three ask each segment for at least one whole batch header
8//! to satisfy Kafka's anti-stall rule. A read goes on into the next segment
9//! only after it reads the segment before it to the end. Only the first batch
10//! of a read can be larger than its budget.
11
12#[cfg(not(target_os = "wasi"))]
13use super::page_cache;
14use {
15    super::Log,
16    crate::{
17        error::LogError,
18        segment::{RawReadSource as _, ReadSource, Segment, SegmentReadView},
19    },
20    bytes::{Bytes, BytesMut},
21    krabka_ids::Offset,
22    krabka_protocol::records::{HEADER_LEN, RecordBatch},
23    krabka_units::prelude::{ByteSize, ByteSizeExt, bytes},
24    std::{
25        ops::Range,
26        sync::{
27            Arc, PoisonError,
28            atomic::{AtomicU64, Ordering},
29        },
30    },
31    tracing::{field, instrument},
32};
33#[krabka_macros::sendfile_cfg]
34use {crate::segment::RawDescSource as _, krabka_protocol::records::FileRegion, std::cell::Cell};
35
36/// A `usize` byte length as a quantity.
37///
38/// The read paths track their budget as a [`ByteSize`], but the lengths they
39/// subtract come from `Bytes::len` and `FileRegion::len`, which are `usize`.
40fn size_from_len(len: usize) -> ByteSize {
41    ByteSize::from_bytes(u64::try_from(len).unwrap_or(u64::MAX))
42}
43
44/// The fixed v2 record-batch header, as a quantity.
45///
46/// The log asks every per-segment read for at least this much, so the header
47/// walk that finds batch boundaries always has a header to read. This is
48/// Kafka's anti-stall rule. The value comes from [`HEADER_LEN`] rather than a
49/// restated constant, so the floor cannot drift from the protocol.
50fn batch_header() -> ByteSize { size_from_len(HEADER_LEN) }
51
52/// Whether a read that took a run out of `segment` can go on into the next
53/// segment.
54///
55/// `next` is the offset after the run. The read can go on only when the run
56/// reached the end of the segment. A run that stopped before the end stopped
57/// on the budget or on the limit. The next segment starts after batches that
58/// this segment still holds. If the read went on, the response would put the
59/// first batch of the next segment after the run, and skip every offset
60/// between them. Kafka's `LocalLog.read` never leaves the segment that it
61/// found data in.
62fn emptied(segment: &impl ReadSource, next: Offset) -> bool { next > segment.last() }
63
64/// How far behind a cold fetch of up to `max_size` bytes the pages its
65/// reader's previous fetch brought in can start: twice the size, since only
66/// a fetch's first batch may be larger than the size it asked for. See
67/// `Log::drop_cold`.
68fn cold_lookback(max_size: ByteSize) -> u64 { max_size.bytes_u64().saturating_mul(2) }
69
70/// Whether a run of `len` bytes fits the `remaining` budget of a read that
71/// already has its first batch.
72///
73/// Each segment read gives its first batch whole, however small its budget.
74/// That is Kafka's anti-stall rule, and KIP-74 applies it only to the first
75/// batch of a read. A later run that is larger than the remaining budget
76/// waits for the next read.
77fn fits_after_first(len: ByteSize, remaining: ByteSize) -> bool { len <= remaining }
78
79/// The progress of a raw read and the chunks it collected.
80struct RawChunks<T> {
81    cursor: RawReadCursor,
82    chunks: Vec<T>,
83}
84
85/// Shared progress for verbatim bytes and file regions, including the first-batch exception.
86struct RawReadCursor {
87    start: Offset,
88    next: Offset,
89    remaining: ByteSize,
90    last: Option<Offset>,
91}
92
93impl RawReadCursor {
94    fn new(offset: Offset, budget: ByteSize) -> Self {
95        Self {
96            start: offset,
97            next: offset,
98            remaining: budget,
99            last: None,
100        }
101    }
102
103    fn budget(&self) -> ByteSize { self.remaining.max(batch_header()) }
104
105    fn accept(&mut self, start: Offset, last: Offset, bytes: usize) -> bool {
106        let len = size_from_len(bytes);
107        if self.last.is_some() && !fits_after_first(len, self.remaining) {
108            return false;
109        }
110        if self.last.is_none() {
111            self.start = start;
112        }
113        self.remaining = (self.remaining - len).max(ByteSize::ZERO);
114        self.next = last + 1;
115        self.last = Some(last);
116        true
117    }
118
119    fn finished(&self, segment: &impl ReadSource, limit: Offset) -> bool {
120        self.remaining == ByteSize::ZERO || self.next >= limit || !emptied(segment, self.next)
121    }
122}
123
124/// A complete segment result with its offset span and byte cost.
125struct RawChunk<T> {
126    data: T,
127    start: Offset,
128    last: Offset,
129    len: usize,
130}
131
132/// Result of [`Log::read`]: the absolute offset of the first batch
133/// returned and the batches themselves.
134///
135/// `start_offset` falls back to the requested offset when the read returns
136/// no batches, for example a read at the log end. Callers can then resume
137/// from the value and need no special case for an empty result.
138#[derive(Debug)]
139pub struct ReadOutput {
140    /// Absolute offset of the first record in [`Self::batches`], or the
141    /// requested offset when no batches were returned.
142    pub start_offset: Offset,
143    /// Decoded batches in offset order. May be empty if the log has no
144    /// data at or after the requested offset.
145    pub batches: Vec<RecordBatch>,
146}
147
148/// Verbatim, decode-free output of [`Log::read_raw`].
149#[derive(Debug, Clone)]
150pub struct RawRead {
151    /// Absolute offset of the first batch in [`Self::bytes`], or the
152    /// requested offset when no bytes were returned.
153    pub start_offset: Offset,
154    /// Verbatim `.log` bytes: zero or more complete v2 batches. The bytes
155    /// can span segment boundaries.
156    pub bytes: Bytes,
157    /// Length of [`Self::bytes`] in bytes.
158    pub total: usize,
159    /// Last offset included in [`Self::bytes`], or `None` when empty.
160    pub last_offset: Option<Offset>,
161}
162
163#[krabka_macros::sendfile_cfg]
164/// Descriptor form of [`Log::read_raw`] for the zero-copy `sendfile` fetch
165/// path, Increments D + E.
166///
167/// One [`krabka_protocol::records::FileRegion`] describes the records run
168/// for each contributing segment. A multi-segment fetch therefore goes
169/// out as several `sendfile` regions with **no** coalescing copy.
170/// `read_raw` instead concatenates cross-segment chunks into a fresh
171/// `BytesMut`. This type is compiled on the SENDFILE alias: Linux, Apple,
172/// and FreeBSD/DragonFly.
173#[derive(Debug, Clone)]
174pub struct RawReadDesc {
175    /// Absolute offset of the first batch in the regions, or the requested
176    /// offset when no bytes were returned.
177    pub start_offset: Offset,
178    /// One file-backed region per contributing segment, in wire order.
179    pub regions: Vec<FileRegion>,
180    /// Total byte length across all regions.
181    pub total: usize,
182}
183
184impl Log {
185    /// The index in `self.segments` of the first sealed segment that can hold
186    /// `offset`, or `self.segments.len()` when none can and a read starts in
187    /// the active segment.
188    ///
189    /// That is the first sealed segment whose last offset is at or past
190    /// `offset`: the segment a walk from the oldest one would stop skipping
191    /// at. The search is a binary search, so a tail fetch does not cost a
192    /// step for every retained segment.
193    ///
194    /// It is exact because sealed last offsets never decrease. Bases strictly
195    /// increase, and a segment's last offset lies between its own base less
196    /// one, for an empty segment, and the next base less one. A compacted gap
197    /// lowers a last offset only within that range, and a reopened segment
198    /// takes the next base less one.
199    fn first_sealed_from(&self, offset: Offset) -> usize {
200        self.segments
201            .partition_point(|segment| segment.last_offset() < offset)
202    }
203
204    /// The segments a read from `offset` walks, in offset order: the sealed
205    /// ones from [`Self::first_sealed_from`] on, then the active one.
206    fn segments_from(&self, offset: Offset) -> impl Iterator<Item = &Segment> {
207        self.segments
208            .iter()
209            .skip(self.first_sealed_from(offset))
210            .chain(self.active.as_ref())
211    }
212
213    /// Plan a raw read from `fetch_offset`, to run once the lock on this log
214    /// is released.
215    ///
216    /// A fetch of cold bytes walks batch headers, asks the kernel to read
217    /// ahead and copies or describes megabytes, and all of it waits on the
218    /// disk or costs system calls: milliseconds per fetch of a consumer that
219    /// is catching up. Under the log lock those milliseconds hold up every
220    /// append of the partition, which a producer sees as its publish p99
221    /// while a backlog drains. The plan takes what the read needs of the
222    /// log in a few `Arc` clones and index lookups, and the read then runs
223    /// on it without the log: [`RawReadPlan::read_bytes`] and
224    /// [`RawReadPlan::read_regions`] give what [`Log::read_raw`] and
225    /// [`Log::read_raw_desc`] would, as of the moment of the plan.
226    ///
227    /// The plan views each segment the read may walk, from the one that
228    /// holds `fetch_offset` on, as [`RawReadPlan`] walks them: a later
229    /// segment is read from the offset after the one before it, once that one
230    /// is read to its end, so every view's offset is known now. Planning stops
231    /// once the views can fill `max_size`, counting each from its index floor,
232    /// which is at or before where its read starts.
233    ///
234    /// # Errors
235    /// Returns [`LogError::OffsetTooLow`] below the log start and
236    /// [`LogError::OffsetBelowLocalStart`] below the local one, as the reads
237    /// do, and an error for an offset too far past a segment for its index.
238    pub fn plan_raw_read(
239        &self,
240        fetch_offset: Offset,
241        limit_offset: Offset,
242        max_size: ByteSize,
243    ) -> Result<RawReadPlan, LogError> {
244        let (read_buffer_cap, read_ahead_max, budget) = {
245            let config = self.config.read().unwrap_or_else(PoisonError::into_inner);
246            (
247                config.read_buffer_cap,
248                config.read_ahead_max,
249                config.page_cache_budget,
250            )
251        };
252        self.check_locally_readable(fetch_offset)?;
253        let mut plan = RawReadPlan {
254            fetch_offset,
255            limit_offset,
256            max_size,
257            read_buffer_cap,
258            read_ahead_max,
259            views: Vec::new(),
260            may_block: self.read_may_block(fetch_offset),
261            truncations: Arc::clone(&self.truncations),
262            truncations_seen: self.truncations.load(Ordering::SeqCst),
263        };
264        if fetch_offset >= limit_offset {
265            return Ok(plan);
266        }
267        let mut next = fetch_offset;
268        let mut planned = 0_u64;
269        for segment in self.segments_from(fetch_offset) {
270            if segment.last_offset() < next {
271                continue;
272            }
273            #[cfg(not(target_os = "wasi"))]
274            let cold_before = self.cold_before(segment, budget);
275            #[cfg(target_os = "wasi")]
276            let cold_before = {
277                let _ = budget;
278                None
279            };
280            let view = segment.read_view(next, cold_before)?;
281            planned = planned.saturating_add(view.bytes_from_floor());
282            plan.views.push(view);
283            if planned >= max_size.bytes_u64() {
284                break;
285            }
286            next = segment.last_offset() + 1;
287            if next >= limit_offset {
288                break;
289            }
290        }
291        // The segment's own verdict knows the page cache and its direct-write
292        // tail, not the budget: pages of the active segment the budget has
293        // dropped are on the disk, and a read that starts in them waits.
294        plan.may_block |= plan.views.first().is_some_and(SegmentReadView::starts_cold);
295        Ok(plan)
296    }
297
298    #[krabka_macros::sendfile_cfg]
299    /// Descriptor variant of [`Log::read_raw`] for the zero-copy `sendfile`
300    /// fetch path.
301    ///
302    /// This method walks sealed segments and then the active segment exactly
303    /// as `read_raw` does. It collects one [`krabka_protocol::records::FileRegion`] for each contributing segment
304    /// through `Segment::read_raw_desc`, instead of owned `Bytes`.
305    /// Multi-segment fetches are **not** coalesced. Each region goes out in
306    /// its own `sendfile` call, so the cross-segment copy disappears.
307    ///
308    /// The selected byte ranges are byte-identical to what `read_raw` would
309    /// have returned for the same `(fetch_offset, limit_offset, max_size)`.
310    /// It plans and runs under the caller's lock: see [`Log::plan_raw_read`]
311    /// for the fetch path's split.
312    #[instrument(
313    level = "debug",
314    skip(self),
315    fields(regions = field::Empty, total = field::Empty),
316    err,
317)]
318    /// # Errors
319    /// Returns an error when log I/O fails, a record or index is corrupt, or the requested offset violates the segment state.
320    pub fn read_raw_desc(
321        &self,
322        fetch_offset: Offset,
323        limit_offset: Offset,
324        max_size: ByteSize,
325    ) -> Result<RawReadDesc, LogError> {
326        self.plan_raw_read(fetch_offset, limit_offset, max_size)?
327            .read_regions()
328    }
329
330    /// The position in `segment`'s `.log` file before which a fetch is cold:
331    /// the page-cache budget no longer holds those bytes, so the pages a fetch
332    /// of them reads in would stay in the cgroup until its limit evicted
333    /// them. `None` when no fetch of the segment is cold: there is no budget.
334    ///
335    /// The budget's ledger drops a file's oldest pages first, so the pages of
336    /// a file it holds are the newest of those handed over, and everything
337    /// before the oldest of them is cold. A sealed segment with no page in the
338    /// ledger is cold throughout: its pages were handed over and dropped, or
339    /// it was written before this process opened the log. (The partial
340    /// write-back window at its end was never handed over and may still be
341    /// cached; a fetch of it counts as cold all the same, and a second reader
342    /// of those few pages reads the disk.) The active segment is cold up to
343    /// where its background syncs have handed pages over, once the ledger
344    /// holds none of them, and hot from there: those pages are its newest.
345    #[cfg(not(target_os = "wasi"))]
346    fn cold_before(&self, segment: &Segment, budget: Option<ByteSize>) -> Option<u64> {
347        budget?;
348        let retained_from = self
349            .rollover_flusher
350            .page_cache_retained_from(segment.log_file());
351        Some(match retained_from {
352            Some(from) => from,
353            None if segment.is_sealed() => u64::MAX,
354            None => self.writeback_handed,
355        })
356    }
357
358    /// Reject an offset no local segment can answer.
359    ///
360    /// Below the global log start there is nothing anywhere, and the error
361    /// says so. Between the global floor and the local one (KIP-405) the
362    /// records are in the remote tier: the read fails all the same, so the
363    /// broker's fetch path falls through to the remote reader rather than
364    /// serving the first batch a surviving segment happens to start with.
365    fn check_locally_readable(&self, offset: Offset) -> Result<(), LogError> {
366        let log_start = self.log_start_offset();
367        if offset < log_start {
368            return Err(LogError::OffsetTooLow {
369                requested: offset,
370                log_start,
371            });
372        }
373        let local_log_start = self.local_log_start_offset();
374        if offset < local_log_start {
375            return Err(LogError::OffsetBelowLocalStart {
376                requested: offset,
377                local_log_start,
378            });
379        }
380        Ok(())
381    }
382
383    /// Read batches from `offset` and return up to `max_size` of `.log`
384    /// data.
385    ///
386    /// The read walks sealed segments first, then the active segment, so a
387    /// read can span segment boundaries. It goes on into a segment only after
388    /// it reads the segment before it to the end.
389    #[instrument(
390        level = "debug",
391        skip(self),
392        fields(batches = field::Empty),
393        err,
394    )]
395    /// # Errors
396    /// Returns an error when log I/O fails, a record or index is corrupt, or the requested offset violates the segment state.
397    pub fn read(&self, offset: Offset, max_size: ByteSize) -> Result<ReadOutput, LogError> {
398        let log_end = self.log_end_offset();
399        self.check_locally_readable(offset)?;
400        if offset >= log_end {
401            return Ok(ReadOutput {
402                start_offset: log_end,
403                batches: Vec::new(),
404            });
405        }
406
407        let read_buffer_cap = self
408            .config
409            .read()
410            .unwrap_or_else(PoisonError::into_inner)
411            .read_buffer_cap;
412        let mut batches: Vec<RecordBatch> = Vec::new();
413        let mut current_offset = offset;
414        let mut remaining = max_size;
415
416        for seg in self.segments_from(offset) {
417            if seg.last_offset() < current_offset {
418                continue;
419            }
420            let bs =
421                seg.read_with_buffer_cap(current_offset, remaining.max(bytes(1)), read_buffer_cap)?;
422            let Some(last) = bs.last() else {
423                continue;
424            };
425            current_offset = Offset(last.base_offset + i64::from(last.last_offset_delta) + 1);
426            let consumed: usize = bs.iter().map(RecordBatch::encoded_len).sum();
427            remaining = (remaining - size_from_len(consumed)).max(ByteSize::ZERO);
428            batches.extend(bs);
429            if remaining == ByteSize::ZERO || !emptied(seg, current_offset) {
430                break;
431            }
432        }
433
434        let start_offset = batches.first().map_or(offset, |b| Offset(b.base_offset));
435        tracing::Span::current().record("batches", batches.len());
436        Ok(ReadOutput {
437            start_offset,
438            batches,
439        })
440    }
441
442    /// Like [`Log::read`], but returns verbatim wire bytes with no decode.
443    ///
444    /// The read walks sealed segments and then the active segment. It goes on
445    /// into a segment only after it reads the segment before it to the end.
446    /// It includes only batches with `base_offset < limit_offset`, up to
447    /// `max_size`, and always at least one batch. The first batch is included
448    /// whole, even when it is larger than `max_size`. It plans and runs under
449    /// the caller's lock: see [`Log::plan_raw_read`] for the fetch path's
450    /// split.
451    #[instrument(
452        level = "debug",
453        skip(self),
454        fields(total = field::Empty),
455        err,
456    )]
457    /// # Errors
458    /// Returns an error when log I/O fails, a record or index is corrupt, or the requested offset violates the segment state.
459    pub fn read_raw(
460        &self,
461        fetch_offset: Offset,
462        limit_offset: Offset,
463        max_size: ByteSize,
464    ) -> Result<RawRead, LogError> {
465        self.plan_raw_read(fetch_offset, limit_offset, max_size)?
466            .read_bytes()
467    }
468}
469
470/// A raw read planned under the log lock, to run without it.
471///
472/// The plan holds a view of each segment the read may walk, taken by
473/// [`Log::plan_raw_read`], and the read's bounds and policy. Running it is
474/// the header walks, the readahead hints, the copies or descriptions and the
475/// drops of cold pages that [`Log::read_raw`] and [`Log::read_raw_desc`] do,
476/// and it needs nothing of the log: the views hold the file handles, and
477/// read up to the sizes they saw.
478#[derive(Debug, Clone)]
479pub struct RawReadPlan {
480    fetch_offset: Offset,
481    limit_offset: Offset,
482    max_size: ByteSize,
483    read_buffer_cap: ByteSize,
484    read_ahead_max: ByteSize,
485    views: Vec<SegmentReadView>,
486    may_block: bool,
487    /// The log's truncation count, and its value when the plan was taken:
488    /// see `Log::truncations`.
489    truncations: Arc<AtomicU64>,
490    truncations_seen: u64,
491}
492
493impl RawReadPlan {
494    /// Whether running the plan may wait on more than the page cache:
495    /// [`Log::read_may_block`] for the read's offset, as of the plan. A
496    /// caller that must not block a runtime thread runs such a plan away
497    /// from it.
498    #[must_use]
499    pub fn may_block(&self) -> bool { self.may_block }
500
501    /// The offset the read cannot reach: the end of the last segment the plan
502    /// may walk, or the read's limit, whichever is lower; the read's own
503    /// offset when there is nothing to walk. A caller that needs the log's
504    /// view of the bytes the read may serve, such as the aborted transactions
505    /// a `read_committed` fetch reports beside them, takes it for this range
506    /// under the lock with the plan: bounded by the segment or two the read
507    /// may touch, not by the whole log behind it.
508    #[must_use]
509    pub fn offset_bound(&self) -> Offset {
510        self.views.last().map_or(self.fetch_offset, |view| {
511            (view.last() + 1).min(self.limit_offset)
512        })
513    }
514
515    /// Whether the log has cut a segment file since the plan was taken. The
516    /// bytes a read copied by then may mix what stood before the cut with
517    /// what was written after it, so the read serves nothing, and the next
518    /// fetch plans again against the log as it is now.
519    fn truncated_since(&self) -> bool {
520        self.truncations.load(Ordering::SeqCst) != self.truncations_seen
521    }
522
523    /// Run the plan as [`Log::read_raw`] would have read.
524    ///
525    /// # Errors
526    /// Returns an error when log I/O fails or a record or index is corrupt.
527    pub fn read_bytes(&self) -> Result<RawRead, LogError> {
528        let lookback = cold_lookback(self.max_size);
529        let RawChunks { cursor, mut chunks } = self.chunks(|view, next, remaining| {
530            let read = view.read_raw_with_policy(
531                next,
532                self.limit_offset,
533                remaining,
534                self.read_buffer_cap,
535                self.read_ahead_max,
536            )?;
537            if read.is_empty() {
538                return Ok(None);
539            }
540            if let Some(file_range) = &read.file_range {
541                drop_cold(view, file_range.clone(), lookback, true);
542            }
543            Ok(Some(RawChunk {
544                len: read.bytes.len(),
545                data: read.bytes,
546                start: read.start_offset,
547                last: read.last_offset,
548            }))
549        })?;
550
551        if self.truncated_since() {
552            chunks.clear();
553        }
554        let bytes = match chunks.len() {
555            // No chunk is empty bytes, and one chunk is itself.
556            0 | 1 => chunks.pop().unwrap_or_default(),
557            _ => {
558                let total: usize = chunks.iter().map(Bytes::len).sum();
559                let mut b = BytesMut::with_capacity(total);
560                for c in &chunks {
561                    b.extend_from_slice(c);
562                }
563                b.freeze()
564            }
565        };
566        let total = bytes.len();
567        tracing::Span::current().record("total", total);
568        let served = total > 0;
569        Ok(RawRead {
570            start_offset: if served {
571                cursor.start
572            } else {
573                self.fetch_offset
574            },
575            bytes,
576            total,
577            last_offset: cursor.last.filter(|_| served),
578        })
579    }
580
581    /// Run the plan as [`Log::read_raw_desc`] would have read.
582    ///
583    /// # Errors
584    /// Returns an error when log I/O fails or a record or index is corrupt.
585    #[krabka_macros::sendfile_cfg]
586    pub fn read_regions(&self) -> Result<RawReadDesc, LogError> {
587        let lookback = cold_lookback(self.max_size);
588        // A segment whose run would be walked on bytes that are not in the
589        // page cache is not described (`read_raw_desc_if_cached`). The walk
590        // stops there, so the fetch sends the runs before it, or, when it
591        // starts in that segment, gets nothing and falls back to the
592        // byte-copy path, which reads the run in one large read. Passing over
593        // it would send a later segment's runs and skip its offsets.
594        let stopped = Cell::new(false);
595        let RawChunks {
596            cursor,
597            chunks: mut regions,
598        } = self.chunks(|view, next, remaining| {
599            if stopped.get() {
600                return Ok(None);
601            }
602            let Some(read) = view.read_raw_desc_if_cached(
603                next,
604                self.limit_offset,
605                remaining,
606                self.read_ahead_max,
607            )?
608            else {
609                stopped.set(true);
610                return Ok(None);
611            };
612            if let Some(region) = &read.region {
613                let end = region.offset.saturating_add(region.len as u64);
614                drop_cold(view, region.offset..end, lookback, false);
615            }
616            Ok(read.region.map(|region| RawChunk {
617                len: region.len,
618                data: region,
619                start: read.start_offset,
620                last: read.last_offset,
621            }))
622        })?;
623
624        if self.truncated_since() {
625            regions.clear();
626        }
627        let total: usize = regions.iter().map(|r| r.len).sum();
628        let span = tracing::Span::current();
629        span.record("regions", regions.len());
630        span.record("total", total);
631        Ok(RawReadDesc {
632            start_offset: if regions.is_empty() {
633                self.fetch_offset
634            } else {
635                cursor.start
636            },
637            regions,
638            total,
639        })
640    }
641
642    /// The walk both reads share: each view in turn, from the offset after
643    /// the one before it, while the budget and the limit allow and the one
644    /// before was read to its end.
645    fn chunks<T>(
646        &self,
647        mut read: impl FnMut(
648            &SegmentReadView,
649            Offset,
650            ByteSize,
651        ) -> Result<Option<RawChunk<T>>, LogError>,
652    ) -> Result<RawChunks<T>, LogError> {
653        let mut cursor = RawReadCursor::new(self.fetch_offset, self.max_size);
654        let mut chunks = Vec::new();
655        for view in &self.views {
656            if view.last() < cursor.next {
657                continue;
658            }
659            let Some(chunk) = read(view, cursor.next, cursor.budget())? else {
660                continue;
661            };
662            if !cursor.accept(chunk.start, chunk.last, chunk.len) {
663                break;
664            }
665            chunks.push(chunk.data);
666            if cursor.finished(view, self.limit_offset) {
667                break;
668            }
669        }
670        Ok(RawChunks { cursor, chunks })
671    }
672}
673
674/// Drop from the page cache what a fetch of `range` of `view`'s `.log` file
675/// leaves there, when the range is cold: see [`Log::cold_before`]. A
676/// `sendfile` fetch sends its bytes after this returns, so it drops the pages
677/// behind it, where its reader's previous fetch was, back to `lookback` bytes
678/// before the range. A fetch that already `held` its bytes, having copied
679/// them, drops its own pages as well. Whole pages only, so the page a
680/// `sendfile` range starts in stays for the send.
681///
682/// A consumer catching up on a backlog thus leaves at most a fetch or two of
683/// pages behind, instead of filling the cgroup to its limit and making every
684/// append reclaim: see [`page_cache`].
685#[cfg(not(target_os = "wasi"))]
686fn drop_cold(view: &SegmentReadView, range: Range<u64>, lookback: u64, held: bool) {
687    let Some(cold_before) = view.cold_before() else {
688        return;
689    };
690    if range.end > cold_before {
691        return;
692    }
693    let from = range.start.saturating_sub(lookback) & !(page_cache::PAGE - 1);
694    let to = if held {
695        range.end
696    } else {
697        range.start & !(page_cache::PAGE - 1)
698    };
699    if to > from {
700        view.drop_cached(from, to - from);
701    }
702}
703
704/// WASI has no flush pool, no page-cache ledger and no fetch that is cold.
705#[cfg(target_os = "wasi")]
706fn drop_cold(view: &SegmentReadView, range: Range<u64>, lookback: u64, held: bool) {
707    let _ = (view, range, lookback, held);
708}
709
710#[cfg(test)]
711mod tests;