mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 16:57:00 +01:00
init
This commit is contained in:
commit
a1b345a029
80 files changed
+28033
No files matched your search
@@ -0,0 +1,338 @@
|
||||
//! The in-memory block cache: a byte-budgeted LRU shared by every open stream.
|
||||
//!
|
||||
//! Sized in **bytes**, not entries. A block is 4 MiB in practice but nothing
|
||||
//! guarantees it, and a count-based bound would turn into a wildly different
|
||||
//! memory ceiling on a library uploaded by a different client. The budget is the
|
||||
//! app's actual resident-memory promise, so it is what gets counted.
|
||||
//!
|
||||
//! Shared across streams rather than one per stream: a viewer who abandons an
|
||||
//! episode after ninety seconds should not keep 128 MiB of it resident while the
|
||||
//! next one starves.
|
||||
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use lru::LruCache;
|
||||
use proton_sdk::ids::NodeUid;
|
||||
|
||||
/// Identifies one decrypted block, globally.
|
||||
///
|
||||
/// The revision id is part of the key on purpose: a file that gains a new
|
||||
/// revision must not serve a byte of the old one out of cache. It is a stable
|
||||
/// identity that advances iff a new revision was sealed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct BlockKey {
|
||||
pub uid: NodeUid,
|
||||
pub revision: String,
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
impl BlockKey {
|
||||
pub(crate) fn new(uid: &NodeUid, revision: &str, index: usize) -> Self {
|
||||
Self {
|
||||
uid: uid.clone(),
|
||||
revision: revision.to_string(),
|
||||
index,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A decrypted block, shared by reference so a cache hit costs no copy.
|
||||
pub(crate) type CachedBlock = Arc<[u8]>;
|
||||
|
||||
/// Hit/miss counters, for the benchmark and the settings screen.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct RingStats {
|
||||
pub hits: u64,
|
||||
pub misses: u64,
|
||||
/// Blocks dropped to stay inside the budget.
|
||||
pub evictions: u64,
|
||||
/// Bytes currently resident.
|
||||
pub resident_bytes: u64,
|
||||
pub blocks: usize,
|
||||
}
|
||||
|
||||
pub(crate) struct BlockRing {
|
||||
inner: Mutex<Inner>,
|
||||
budget: u64,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
/// Unbounded by entry count — eviction is driven by `bytes` against the
|
||||
/// budget, below.
|
||||
lru: LruCache<BlockKey, CachedBlock>,
|
||||
bytes: u64,
|
||||
hits: u64,
|
||||
misses: u64,
|
||||
evictions: u64,
|
||||
}
|
||||
|
||||
impl BlockRing {
|
||||
pub(crate) fn new(budget_bytes: u64) -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(Inner {
|
||||
lru: LruCache::unbounded(),
|
||||
bytes: 0,
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
evictions: 0,
|
||||
}),
|
||||
budget: budget_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
/// Look a block up, marking it most-recently-used on a hit.
|
||||
pub(crate) fn get(&self, key: &BlockKey) -> Option<CachedBlock> {
|
||||
let mut inner = self.lock();
|
||||
match inner.lru.get(key) {
|
||||
Some(block) => {
|
||||
let block = Arc::clone(block);
|
||||
inner.hits += 1;
|
||||
Some(block)
|
||||
}
|
||||
None => {
|
||||
inner.misses += 1;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a block, evicting until the budget is met.
|
||||
///
|
||||
/// A block larger than the whole budget is *not* stored — storing it would
|
||||
/// evict everything else and then still be over. It is still returned to the
|
||||
/// caller that fetched it; it simply is not cached.
|
||||
pub(crate) fn insert(&self, key: BlockKey, block: CachedBlock) {
|
||||
let len = block.len() as u64;
|
||||
if len > self.budget {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut inner = self.lock();
|
||||
if let Some(previous) = inner.lru.put(key, block) {
|
||||
inner.bytes -= previous.len() as u64;
|
||||
}
|
||||
inner.bytes += len;
|
||||
|
||||
while inner.bytes > self.budget {
|
||||
match inner.lru.pop_lru() {
|
||||
Some((_, evicted)) => {
|
||||
inner.bytes -= evicted.len() as u64;
|
||||
inner.evictions += 1;
|
||||
}
|
||||
// Unreachable while `bytes` and `lru` agree; not worth a panic.
|
||||
None => {
|
||||
inner.bytes = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a block is resident, *without* counting a hit or touching its
|
||||
/// recency. Read-ahead asks this to decide what to prefetch, and it must not
|
||||
/// look like demand traffic in the stats or in the LRU order.
|
||||
pub(crate) fn contains(&self, key: &BlockKey) -> bool {
|
||||
self.lock().lru.contains(key)
|
||||
}
|
||||
|
||||
/// The byte budget this ring was built with.
|
||||
pub(crate) fn budget(&self) -> u64 {
|
||||
self.budget
|
||||
}
|
||||
|
||||
pub(crate) fn stats(&self) -> RingStats {
|
||||
let inner = self.lock();
|
||||
RingStats {
|
||||
hits: inner.hits,
|
||||
misses: inner.misses,
|
||||
evictions: inner.evictions,
|
||||
resident_bytes: inner.bytes,
|
||||
blocks: inner.lru.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop every block of one revision. Used when a stream is closed and its
|
||||
/// bytes are known to be dead.
|
||||
pub(crate) fn forget(&self, uid: &NodeUid, revision: &str) {
|
||||
let mut inner = self.lock();
|
||||
let doomed: Vec<BlockKey> = inner
|
||||
.lru
|
||||
.iter()
|
||||
.filter(|(key, _)| &key.uid == uid && key.revision == revision)
|
||||
.map(|(key, _)| key.clone())
|
||||
.collect();
|
||||
for key in doomed {
|
||||
if let Some(block) = inner.lru.pop(&key) {
|
||||
inner.bytes -= block.len() as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
|
||||
// Non-poisoning for the same reason the rest of the stack is: a panic in
|
||||
// one read must not make every later read fail. The cache holds no
|
||||
// invariant a panic could leave half-updated.
|
||||
self.inner.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
}
|
||||
|
||||
/// The default in-memory budget: 128 MiB, matching the FUSE client's stream
|
||||
/// ring. At 4 MiB a block that is 32 blocks — about two minutes of a 1080p
|
||||
/// episode, enough to absorb a scrub backwards without refetching.
|
||||
pub const DEFAULT_RING_BYTES: u64 = 128 * 1024 * 1024;
|
||||
|
||||
/// `lru` wants a non-zero capacity for bounded caches, and a configured zero
|
||||
/// must clamp rather than panic.
|
||||
pub(crate) fn non_zero(value: usize) -> NonZeroUsize {
|
||||
NonZeroUsize::new(value.max(1)).expect("max(1) is non-zero")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proton_sdk::ids::{LinkId, VolumeId};
|
||||
|
||||
fn uid(link: &str) -> NodeUid {
|
||||
NodeUid::new(VolumeId::new("vol"), LinkId::new(link))
|
||||
}
|
||||
|
||||
fn block(byte: u8, len: usize) -> CachedBlock {
|
||||
vec![byte; len].into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_block_reads_back() {
|
||||
let ring = BlockRing::new(1024);
|
||||
let key = BlockKey::new(&uid("a"), "rev1", 0);
|
||||
ring.insert(key.clone(), block(7, 16));
|
||||
|
||||
assert_eq!(ring.get(&key).expect("hit").as_ref(), &[7_u8; 16]);
|
||||
assert_eq!(ring.stats().hits, 1);
|
||||
}
|
||||
|
||||
/// The budget is the app's memory promise. Exceeding it is the one thing
|
||||
/// this type must not do.
|
||||
#[test]
|
||||
fn the_ring_never_exceeds_its_byte_budget() {
|
||||
let ring = BlockRing::new(100);
|
||||
for index in 0..10 {
|
||||
ring.insert(BlockKey::new(&uid("a"), "rev1", index), block(1, 30));
|
||||
assert!(
|
||||
ring.stats().resident_bytes <= 100,
|
||||
"over budget at block {index}"
|
||||
);
|
||||
}
|
||||
assert_eq!(ring.stats().blocks, 3);
|
||||
assert!(ring.stats().evictions > 0);
|
||||
}
|
||||
|
||||
/// Eviction order must be least-recently-*used*, not insertion order, or a
|
||||
/// viewer scrubbing back and forth loses the block they keep returning to.
|
||||
#[test]
|
||||
fn the_least_recently_used_block_is_evicted_first() {
|
||||
let ring = BlockRing::new(60);
|
||||
let (a, b) = (
|
||||
BlockKey::new(&uid("x"), "rev1", 0),
|
||||
BlockKey::new(&uid("x"), "rev1", 1),
|
||||
);
|
||||
ring.insert(a.clone(), block(1, 30));
|
||||
ring.insert(b.clone(), block(2, 30));
|
||||
|
||||
// Touch `a`, making `b` the eviction candidate.
|
||||
assert!(ring.get(&a).is_some());
|
||||
ring.insert(BlockKey::new(&uid("x"), "rev1", 2), block(3, 30));
|
||||
|
||||
assert!(ring.get(&a).is_some(), "recently used block was evicted");
|
||||
assert!(ring.get(&b).is_none(), "stale block survived");
|
||||
}
|
||||
|
||||
/// The reason the revision id is in the key: a resealed file must never
|
||||
/// serve a byte of its previous content.
|
||||
#[test]
|
||||
fn a_new_revision_does_not_hit_the_old_revisions_blocks() {
|
||||
let ring = BlockRing::new(1024);
|
||||
ring.insert(BlockKey::new(&uid("a"), "rev1", 0), block(1, 8));
|
||||
|
||||
assert!(ring.get(&BlockKey::new(&uid("a"), "rev2", 0)).is_none());
|
||||
}
|
||||
|
||||
/// And neither must two different files at the same block index.
|
||||
#[test]
|
||||
fn two_files_do_not_collide_at_the_same_block_index() {
|
||||
let ring = BlockRing::new(1024);
|
||||
ring.insert(BlockKey::new(&uid("a"), "rev1", 0), block(1, 8));
|
||||
ring.insert(BlockKey::new(&uid("b"), "rev1", 0), block(2, 8));
|
||||
|
||||
assert_eq!(
|
||||
ring.get(&BlockKey::new(&uid("a"), "rev1", 0)).unwrap()[0],
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
ring.get(&BlockKey::new(&uid("b"), "rev1", 0)).unwrap()[0],
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
/// Read-ahead polls this constantly; it must not distort demand statistics
|
||||
/// or promote a block it only *considered* fetching.
|
||||
#[test]
|
||||
fn a_containment_check_records_no_traffic_and_no_recency() {
|
||||
let ring = BlockRing::new(60);
|
||||
let (a, b) = (
|
||||
BlockKey::new(&uid("x"), "rev1", 0),
|
||||
BlockKey::new(&uid("x"), "rev1", 1),
|
||||
);
|
||||
ring.insert(a.clone(), block(1, 30));
|
||||
ring.insert(b.clone(), block(2, 30));
|
||||
|
||||
assert!(ring.contains(&a));
|
||||
assert_eq!(ring.stats().hits, 0);
|
||||
assert_eq!(ring.stats().misses, 0);
|
||||
|
||||
// `a` is still the LRU victim despite having been asked about.
|
||||
ring.insert(BlockKey::new(&uid("x"), "rev1", 2), block(3, 30));
|
||||
assert!(!ring.contains(&a));
|
||||
}
|
||||
|
||||
/// A block bigger than the whole budget must not flush the cache to make
|
||||
/// room for something that cannot fit anyway.
|
||||
#[test]
|
||||
fn a_block_larger_than_the_budget_is_not_stored() {
|
||||
let ring = BlockRing::new(50);
|
||||
let keeper = BlockKey::new(&uid("a"), "rev1", 0);
|
||||
ring.insert(keeper.clone(), block(1, 40));
|
||||
ring.insert(BlockKey::new(&uid("a"), "rev1", 1), block(2, 500));
|
||||
|
||||
assert!(ring.get(&keeper).is_some(), "existing block was flushed");
|
||||
assert_eq!(ring.stats().blocks, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgetting_a_revision_reclaims_its_bytes() {
|
||||
let ring = BlockRing::new(1024);
|
||||
for index in 0..4 {
|
||||
ring.insert(BlockKey::new(&uid("a"), "rev1", index), block(1, 32));
|
||||
}
|
||||
ring.insert(BlockKey::new(&uid("b"), "rev1", 0), block(2, 32));
|
||||
|
||||
ring.forget(&uid("a"), "rev1");
|
||||
let stats = ring.stats();
|
||||
assert_eq!(stats.blocks, 1);
|
||||
assert_eq!(stats.resident_bytes, 32);
|
||||
}
|
||||
|
||||
/// Re-inserting the same key must not double-count its bytes; a refetched
|
||||
/// block would otherwise inflate the accounting until the ring evicted
|
||||
/// everything.
|
||||
#[test]
|
||||
fn reinserting_a_block_does_not_double_count_its_bytes() {
|
||||
let ring = BlockRing::new(1024);
|
||||
let key = BlockKey::new(&uid("a"), "rev1", 0);
|
||||
ring.insert(key.clone(), block(1, 64));
|
||||
ring.insert(key.clone(), block(1, 64));
|
||||
|
||||
assert_eq!(ring.stats().resident_bytes, 64);
|
||||
assert_eq!(ring.stats().blocks, 1);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user