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}