Skip to main content

oxideav_source/
buffered.rs

1//! Prefetch-ring-buffer wrapper around any `ReadSeek`.
2//!
3//! A worker thread owns the inner source and continuously fills a ring
4//! buffer ahead of the read cursor. Reads serve from the ring; seeks
5//! either move the cursor inside the ring (no IO) or restart the worker
6//! at the new offset.
7//!
8//! Designed for streaming playback over a slow source (HTTP).
9//!
10//! ## Tuning
11//!
12//! Defaults work for typical HTTP playback: 256 KiB block reads from the
13//! inner source, a 30 s reader-side prefetch timeout, and a 1/8 lookback
14//! retention (the ring keeps ~12.5 % of its capacity behind the reader to
15//! satisfy short back-seeks without re-fetching). All four knobs —
16//! capacity, block size, lookback fraction, and prefetch timeout — are
17//! exposed via [`BufferedSource::builder`] for callers whose source has a
18//! different latency or transfer profile.
19//!
20//! Constructing a `BufferedSource` via [`BufferedSource::new`] keeps the
21//! historical signature (`capacity` only) and resolves the other knobs to
22//! their defaults.
23
24use std::collections::VecDeque;
25use std::io::{self, Read, Seek, SeekFrom};
26use std::sync::{Arc, Condvar, Mutex};
27use std::thread::{self, JoinHandle};
28use std::time::Duration;
29
30use oxideav_core::ReadSeek;
31
32/// Default worker block size in bytes (`block_size`).
33pub const DEFAULT_BLOCK: usize = 256 * 1024;
34
35/// Default reader-side prefetch wait timeout. A read that has to wait
36/// for the worker longer than this surfaces `io::ErrorKind::TimedOut`
37/// instead of hanging forever; useful when the inner source has stalled
38/// (e.g. a frozen HTTP connection).
39pub const DEFAULT_PREFETCH_TIMEOUT: Duration = Duration::from_secs(30);
40
41/// Default lookback fraction numerator. The ring keeps the most recent
42/// `capacity * LOOKBACK_NUM / LOOKBACK_DEN` bytes behind the reader so a
43/// short back-seek hits the ring instead of restarting prefetch. The
44/// default 1/8 (~12.5 %) matches the historical hardcoded value.
45pub const DEFAULT_LOOKBACK_NUM: u32 = 1;
46/// Default lookback fraction denominator. See [`DEFAULT_LOOKBACK_NUM`].
47pub const DEFAULT_LOOKBACK_DEN: u32 = 8;
48
49/// Shared state between reader and worker.
50struct RingState {
51    /// Bytes prefetched, oldest first. `buf[0]` corresponds to `ring_start`.
52    buf: VecDeque<u8>,
53    /// Absolute offset of `buf[0]` in the inner source.
54    ring_start: u64,
55    /// Maximum number of bytes the ring may hold.
56    capacity: usize,
57    /// Worker block size: maximum bytes the worker reads from the inner
58    /// source per `read` syscall. Stored on the state so the reader-side
59    /// "free space" check can cap each worker fill at this value without
60    /// the worker having to re-export it.
61    block_size: usize,
62    /// Lookback-fraction numerator: ring retains at least
63    /// `capacity * lookback_num / lookback_den` bytes behind the reader.
64    lookback_num: u32,
65    /// Lookback-fraction denominator. See [`lookback_num`].
66    lookback_den: u32,
67    /// Total length of inner source, if known.
68    total_len: Option<u64>,
69    /// Worker has reached EOF at the current ring tail.
70    eof: bool,
71    /// Sticky error from the worker; surfaced on the next reader call.
72    err: Option<io::Error>,
73    /// Reader has set this to ask the worker to discard the ring and
74    /// reposition the inner source. The worker clears it when it has acted.
75    target_pos: Option<u64>,
76    /// Reader is gone; worker should exit promptly.
77    stop: bool,
78}
79
80struct Shared {
81    state: Mutex<RingState>,
82    not_full: Condvar,
83    not_empty: Condvar,
84}
85
86/// Builder for [`BufferedSource`]. Exposes every prefetch tunable for
87/// callers whose source has a non-default latency or transfer profile;
88/// callers that just want a sensible buffer can stay on
89/// [`BufferedSource::new`].
90///
91/// Defaults match `BufferedSource::new`: 1 MiB capacity, [`DEFAULT_BLOCK`]
92/// block size, [`DEFAULT_PREFETCH_TIMEOUT`] reader timeout, and
93/// `DEFAULT_LOOKBACK_NUM / DEFAULT_LOOKBACK_DEN` lookback fraction.
94///
95/// All tunables are clamped on `build` so the worker is always able to
96/// make forward progress regardless of the values handed in:
97///
98/// * `capacity` is rounded up to at least `4 * block_size` bytes so each
99///   block read fits in the ring with three more behind it.
100/// * `block_size` is rounded up to `4 KiB` if smaller (an inner `read` of
101///   a few bytes per syscall would dominate the worker's wall time).
102/// * `lookback_num / lookback_den` is clamped to the range `[0, 1)` —
103///   a denominator of zero is treated as "no lookback" and a numerator
104///   matching the denominator is dropped to "(den - 1) / den" so the
105///   reader always has at least one byte of forward window.
106/// * `prefetch_timeout` is clamped to a minimum of 1 ms so a misconfigured
107///   `Duration::ZERO` does not flap reads through `TimedOut` immediately.
108#[derive(Clone, Debug)]
109pub struct BufferedSourceBuilder {
110    capacity: usize,
111    block_size: usize,
112    prefetch_timeout: Duration,
113    lookback_num: u32,
114    lookback_den: u32,
115}
116
117impl Default for BufferedSourceBuilder {
118    fn default() -> Self {
119        Self {
120            capacity: 1024 * 1024,
121            block_size: DEFAULT_BLOCK,
122            prefetch_timeout: DEFAULT_PREFETCH_TIMEOUT,
123            lookback_num: DEFAULT_LOOKBACK_NUM,
124            lookback_den: DEFAULT_LOOKBACK_DEN,
125        }
126    }
127}
128
129impl BufferedSourceBuilder {
130    /// New builder with all knobs at their defaults.
131    pub fn new() -> Self {
132        Self::default()
133    }
134
135    /// Set the ring capacity in bytes. Will be clamped up to at least
136    /// `4 * block_size` on `build` so the ring always holds several
137    /// worker blocks.
138    pub fn capacity(mut self, bytes: usize) -> Self {
139        self.capacity = bytes;
140        self
141    }
142
143    /// Maximum bytes the worker reads from the inner source per syscall.
144    /// Clamped up to 4 KiB on `build`. Larger values reduce per-syscall
145    /// overhead at the cost of coarser-grained ring fills.
146    pub fn block_size(mut self, bytes: usize) -> Self {
147        self.block_size = bytes;
148        self
149    }
150
151    /// Maximum time a `Read` will block waiting for the worker to push
152    /// fresh bytes. `TimedOut` is surfaced on expiry. Clamped up to 1 ms
153    /// on `build`.
154    pub fn prefetch_timeout(mut self, dt: Duration) -> Self {
155        self.prefetch_timeout = dt;
156        self
157    }
158
159    /// Fraction of the ring kept behind the reader as lookback so short
160    /// backward seeks hit the ring. Expressed as `num/den` to avoid a
161    /// floating-point knob (the worker uses integer division internally).
162    /// `0/N` disables lookback entirely. `N/N` is clamped to `(N-1)/N`
163    /// so the ring keeps a forward window.
164    pub fn lookback_fraction(mut self, num: u32, den: u32) -> Self {
165        self.lookback_num = num;
166        self.lookback_den = den;
167        self
168    }
169
170    /// Build a [`BufferedSource`] from this builder and an inner source.
171    /// Spawns one worker thread that takes ownership of `inner`.
172    pub fn build(self, mut inner: Box<dyn ReadSeek>) -> io::Result<BufferedSource> {
173        // Clamp all knobs to safe ranges. See struct docs for rationale.
174        let block_size = self.block_size.max(4 * 1024);
175        let capacity = self.capacity.max(4 * block_size);
176        let prefetch_timeout = self.prefetch_timeout.max(Duration::from_millis(1));
177        let (lookback_num, lookback_den) = sanitise_lookback(self.lookback_num, self.lookback_den);
178
179        // Determine total length up front (cheap for File / HttpSource).
180        let pos = inner.stream_position()?;
181        let end = inner.seek(SeekFrom::End(0))?;
182        let total_len = Some(end);
183        // Restore position.
184        inner.seek(SeekFrom::Start(pos))?;
185
186        let state = RingState {
187            buf: VecDeque::with_capacity(capacity),
188            ring_start: pos,
189            capacity,
190            block_size,
191            lookback_num,
192            lookback_den,
193            total_len,
194            eof: total_len == Some(pos),
195            err: None,
196            target_pos: None,
197            stop: false,
198        };
199        let shared = Arc::new(Shared {
200            state: Mutex::new(state),
201            not_full: Condvar::new(),
202            not_empty: Condvar::new(),
203        });
204
205        let worker_shared = Arc::clone(&shared);
206        let worker_block = block_size;
207        let worker = thread::spawn(move || worker_loop(worker_shared, inner, worker_block));
208
209        Ok(BufferedSource {
210            shared,
211            pos,
212            prefetch_timeout,
213            worker: Some(worker),
214        })
215    }
216}
217
218/// Clamp `(num, den)` to a valid lookback fraction in `[0, 1)`. A
219/// denominator of zero is treated as "no lookback". `num >= den` is
220/// dropped to `(den.saturating_sub(1), den)` so the ring always keeps
221/// at least one byte of forward window.
222fn sanitise_lookback(num: u32, den: u32) -> (u32, u32) {
223    if den == 0 {
224        return (0, 1);
225    }
226    if num >= den {
227        return (den.saturating_sub(1), den);
228    }
229    (num, den)
230}
231
232/// Buffered, prefetching wrapper around any `ReadSeek`.
233pub struct BufferedSource {
234    shared: Arc<Shared>,
235    /// Reader's logical position in the inner source.
236    pos: u64,
237    /// Reader-side prefetch wait timeout. Reads waiting longer than this
238    /// surface `io::ErrorKind::TimedOut`.
239    prefetch_timeout: Duration,
240    /// Worker handle. `None` only between drop signal and join.
241    worker: Option<JoinHandle<()>>,
242}
243
244impl BufferedSource {
245    /// Wrap `inner`, allocating up to `capacity` bytes for the prefetch
246    /// ring. Spawns one worker thread that takes ownership of `inner`.
247    /// `capacity` is rounded up to at least `4 * `[`DEFAULT_BLOCK`] bytes
248    /// so the worker always has room to make forward progress.
249    ///
250    /// Other knobs (block size, prefetch timeout, lookback fraction)
251    /// take their default values. Use [`BufferedSource::builder`] to
252    /// tune them.
253    pub fn new(inner: Box<dyn ReadSeek>, capacity: usize) -> io::Result<Self> {
254        BufferedSourceBuilder::new().capacity(capacity).build(inner)
255    }
256
257    /// Open a builder for fine-grained control over capacity, block size,
258    /// prefetch timeout, and lookback fraction. The builder consumes
259    /// itself on each setter, returning a fresh value, then `build(inner)`
260    /// yields the running [`BufferedSource`].
261    pub fn builder() -> BufferedSourceBuilder {
262        BufferedSourceBuilder::new()
263    }
264
265    /// Total length of the inner source, if known.
266    pub fn len(&self) -> Option<u64> {
267        self.shared.state.lock().unwrap().total_len
268    }
269
270    /// Whether the inner source is known to be empty. Returns `false` if
271    /// the length couldn't be determined (treat as non-empty).
272    pub fn is_empty(&self) -> bool {
273        matches!(self.len(), Some(0))
274    }
275
276    /// Effective prefetch timeout in use by this `BufferedSource` (after
277    /// builder clamping). Useful for diagnostics where the caller wants
278    /// to confirm the value actually installed.
279    pub fn prefetch_timeout(&self) -> Duration {
280        self.prefetch_timeout
281    }
282}
283
284fn worker_loop(shared: Arc<Shared>, mut inner: Box<dyn ReadSeek>, block_size: usize) {
285    let mut scratch = vec![0u8; block_size];
286    loop {
287        // Phase 1: handle stop / seek requests, wait if ring is full.
288        let to_read: usize;
289        {
290            let mut st = shared.state.lock().unwrap();
291            loop {
292                if st.stop {
293                    return;
294                }
295                if let Some(target) = st.target_pos.take() {
296                    st.buf.clear();
297                    st.ring_start = target;
298                    st.eof = matches!(st.total_len, Some(end) if target >= end);
299                    st.err = None;
300                    // Reader may already be sleeping on not_empty waiting
301                    // for data at the new position. Wake it so it sees the
302                    // updated ring_start / eof state.
303                    shared.not_empty.notify_all();
304                    drop(st);
305                    if let Err(e) = inner.seek(SeekFrom::Start(target)) {
306                        let mut st = shared.state.lock().unwrap();
307                        st.err = Some(e);
308                        shared.not_empty.notify_all();
309                        return;
310                    }
311                    st = shared.state.lock().unwrap();
312                    continue;
313                }
314                if st.eof {
315                    // No more data to fetch; sleep until reader seeks or drops.
316                    st = shared.not_full.wait(st).unwrap();
317                    continue;
318                }
319                let free = st.capacity - st.buf.len();
320                if free == 0 {
321                    // Wait for reader to drain.
322                    st = shared.not_full.wait(st).unwrap();
323                    continue;
324                }
325                to_read = free.min(st.block_size);
326                break;
327            }
328        }
329
330        // Phase 2: read into scratch outside the lock.
331        let read_result = inner.read(&mut scratch[..to_read]);
332
333        // Phase 3: deposit in ring or surface error / EOF.
334        let mut st = shared.state.lock().unwrap();
335        // Reader may have requested a seek while we were reading; if so,
336        // discard what we just read and let phase 1 handle it next loop.
337        if st.target_pos.is_some() || st.stop {
338            continue;
339        }
340        match read_result {
341            Ok(0) => {
342                st.eof = true;
343                shared.not_empty.notify_all();
344            }
345            Ok(n) => {
346                st.buf.extend(scratch[..n].iter().copied());
347                shared.not_empty.notify_all();
348            }
349            Err(e) => {
350                st.err = Some(e);
351                shared.not_empty.notify_all();
352                return;
353            }
354        }
355    }
356}
357
358impl Read for BufferedSource {
359    fn read(&mut self, out: &mut [u8]) -> io::Result<usize> {
360        if out.is_empty() {
361            return Ok(0);
362        }
363        let mut st = self.shared.state.lock().unwrap();
364        loop {
365            if let Some(e) = st.err.take() {
366                return Err(e);
367            }
368            // Position relative to ring_start.
369            let rel = self.pos.saturating_sub(st.ring_start) as usize;
370            // If reader is somehow before ring_start (shouldn't happen — Seek
371            // bumps target_pos), surface as InvalidInput.
372            if self.pos < st.ring_start {
373                return Err(io::Error::new(
374                    io::ErrorKind::InvalidInput,
375                    "BufferedSource: reader behind ring start",
376                ));
377            }
378            if rel < st.buf.len() {
379                // Hit. Copy out using the VecDeque's two contiguous
380                // slices — this is `copy_from_slice` per segment, vastly
381                // faster than an element-wise loop on a million-byte ring.
382                let avail = st.buf.len() - rel;
383                let n = avail.min(out.len());
384                let (front, back) = st.buf.as_slices();
385                if rel < front.len() {
386                    let f_off = rel;
387                    let f_take = (front.len() - f_off).min(n);
388                    out[..f_take].copy_from_slice(&front[f_off..f_off + f_take]);
389                    if f_take < n {
390                        let b_take = n - f_take;
391                        out[f_take..n].copy_from_slice(&back[..b_take]);
392                    }
393                } else {
394                    let b_off = rel - front.len();
395                    out[..n].copy_from_slice(&back[b_off..b_off + n]);
396                }
397                self.pos += n as u64;
398                // If we've consumed past the front of the ring, drop those
399                // bytes so the worker can refill.
400                let drop_n = rel + n;
401                // But keep some slack so backward seeks within recent past
402                // still hit. Use the builder-configured lookback fraction
403                // (default 1/8 of capacity) as the "rear" the reader can
404                // lookback into without re-fetching. Integer math; no
405                // floats. `lookback_den` is sanitised non-zero on build.
406                let rear = (st.capacity as u64)
407                    .saturating_mul(st.lookback_num as u64)
408                    .checked_div(st.lookback_den as u64)
409                    .unwrap_or(0) as usize;
410                if drop_n > rear {
411                    let to_drop = drop_n - rear;
412                    st.buf.drain(..to_drop);
413                    st.ring_start += to_drop as u64;
414                    self.shared.not_full.notify_one();
415                }
416                return Ok(n);
417            }
418            // Miss: at or past the end of the ring.
419            if st.eof {
420                return Ok(0);
421            }
422            // Wait for worker to push more bytes — bounded so a stuck
423            // worker becomes visible rather than deadlocking forever.
424            let timeout = self.prefetch_timeout;
425            let (new_st, wait_result) = self.shared.not_empty.wait_timeout(st, timeout).unwrap();
426            st = new_st;
427            if wait_result.timed_out() && st.err.is_none() && !st.eof {
428                return Err(io::Error::new(
429                    io::ErrorKind::TimedOut,
430                    format!(
431                        "BufferedSource: prefetch timeout ({} ms)",
432                        timeout.as_millis()
433                    ),
434                ));
435            }
436        }
437    }
438}
439
440impl Seek for BufferedSource {
441    fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
442        let mut st = self.shared.state.lock().unwrap();
443        let total = st.total_len;
444        let new_pos: u64 = match from {
445            SeekFrom::Start(n) => n,
446            SeekFrom::Current(d) => add_signed(self.pos, d)?,
447            SeekFrom::End(d) => {
448                let end = total.ok_or_else(|| {
449                    io::Error::new(io::ErrorKind::Unsupported, "stream length unknown")
450                })?;
451                add_signed(end, d)?
452            }
453        };
454        // If the new position is inside the current ring window, just
455        // update the cursor — no IO needed.
456        let ring_end = st.ring_start + st.buf.len() as u64;
457        if new_pos >= st.ring_start && new_pos <= ring_end {
458            self.pos = new_pos;
459            return Ok(new_pos);
460        }
461        // Otherwise tell the worker to reposition the inner source and
462        // restart prefetch from `new_pos`. Reset ring state here under the
463        // lock so that `self.pos == ring_start` is invariant by the time
464        // Seek returns — otherwise a Read call landing before the worker
465        // acts on `target_pos` would see `self.pos < ring_start` (for
466        // backward seeks) and wrongly return "reader behind ring start".
467        st.target_pos = Some(new_pos);
468        st.buf.clear();
469        st.ring_start = new_pos;
470        st.eof = matches!(total, Some(end) if new_pos >= end);
471        st.err = None;
472        self.pos = new_pos;
473        self.shared.not_full.notify_all();
474        self.shared.not_empty.notify_all();
475        Ok(new_pos)
476    }
477}
478
479fn add_signed(base: u64, delta: i64) -> io::Result<u64> {
480    if delta >= 0 {
481        base.checked_add(delta as u64)
482            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "seek overflow"))
483    } else {
484        let mag = delta.unsigned_abs();
485        base.checked_sub(mag)
486            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "seek before start"))
487    }
488}
489
490impl Drop for BufferedSource {
491    fn drop(&mut self) {
492        {
493            let mut st = self.shared.state.lock().unwrap();
494            st.stop = true;
495        }
496        self.shared.not_full.notify_all();
497        self.shared.not_empty.notify_all();
498        if let Some(h) = self.worker.take() {
499            let _ = h.join();
500        }
501    }
502}