comment cleanup

This commit is contained in:
2026-06-03 19:49:08 -04:00
parent 90cbf53a4b
commit e6b4a63d74
+60 -31
View File
@@ -23,23 +23,26 @@ impl Range {
#[derive(Debug)] #[derive(Debug)]
pub struct Blob { pub struct Blob {
// todo: consider fallible collections to detect OOM on append
// space complexity O(n + m) // space complexity O(n + m)
// where n is number of bytes // where n is number of bytes
// and m is number of ranges // where m is number of ranges
// (m should usually be smaller than n, unless reads are very sparse)
// ------------------------- // -------------------------
// Selected VecDeque<Box<[u8;N]>>> for fast push/pop/access // Selected VecDeque<Box<[u8;N]>>> for
// pop_front may re-allocate, but better than vector most of time // fast pop_front and random access, both are O(1)
// also since we use box, we are only copying pointers to the arrays, not each u8 // push_back is usually O(1), but may re-allocate and copy O(n)
// also since we use box, we are only copying pointers to the arrays, not each u8_array
// ------------------------- // -------------------------
// also tried using LinkedList, but slow reading when ranges were inside nodes // also tried using LinkedList, but slow reading O(n^2) when ranges were inside nodes
// and indexing into LinkedList nodes doesn't seem to jive well with Rust's ownership model // and indexing into LinkedList nodes doesn't seem to jive well with Rust's ownership model
// especially when considering multi-threaded access // especially when considering multi-threaded access
// ------------------------- // -------------------------
// Vec<Option<Box<[u8;N]>>> also worth considering, but instead of pop // Vec<Option<Box<[u8;N]>>> also worth considering, but instead of pop
// using None slots to maintain indexes, if arrays are likely to be sparse // using None slots to maintain indexes, if arrays are likely to be sparse
// this would probably be preferrable // this would probably be preferrable
// -------------------------
// note: consider fallible collections to detect OOM on append if needed
// this code will panic if we run out of memory
arrays: VecDeque<Box<[u8; ARRAY_SIZE]>>, // complexity variable n arrays: VecDeque<Box<[u8; ARRAY_SIZE]>>, // complexity variable n
dropped: usize, dropped: usize,
ranges: Vec<Range>, // valid, global // complexity variable m ranges: Vec<Range>, // valid, global // complexity variable m
@@ -64,10 +67,10 @@ impl Blob {
) )
} }
// appending complexity time, worst: O(n + a + m) // appending complexity time, worst: O(n + a + r)
// where n is bytes written // where n is bytes written
// and a is arrays added // where a is arrays added
// and m is ranges added, usually 0 or 1, but reallocation could copy (m) // where r is ranges added, usually 0 or 1, but reallocation could copy (r)
fn append(&mut self, input: &[u8]) -> usize { fn append(&mut self, input: &[u8]) -> usize {
if input.is_empty() { if input.is_empty() {
return 0; return 0;
@@ -80,16 +83,18 @@ impl Blob {
let within = global_offset % ARRAY_SIZE; let within = global_offset % ARRAY_SIZE;
let physical = global_offset / ARRAY_SIZE - self.dropped; let physical = global_offset / ARRAY_SIZE - self.dropped;
if physical == self.arrays.len() { if physical == self.arrays.len() {
// O(1) to push_back // O(1) for single push_back
// O(a) for all arrays that are necessary for the write
self.arrays.push_back(Box::new([0u8; ARRAY_SIZE])); self.arrays.push_back(Box::new([0u8; ARRAY_SIZE]));
} }
// write to array - worst: O(n) // write to array - O(n)
let take = (ARRAY_SIZE - within).min(input.len() - written); let take = (ARRAY_SIZE - within).min(input.len() - written);
let array = &mut self.arrays[physical]; let array = &mut self.arrays[physical];
array[within..within + take].copy_from_slice(&input[written..written + take]); array[within..within + take].copy_from_slice(&input[written..written + take]);
written += take; written += take;
} }
// update the ranges vector - O(1) // update the ranges vector - usually O(1)
// but could push could re-allocate and copy: O(r)
let new_range = Range::new(self.end_index, self.end_index + input.len()); let new_range = Range::new(self.end_index, self.end_index + input.len());
match self.ranges.last_mut() { match self.ranges.last_mut() {
Some(last) if last.end == new_range.start => last.end = new_range.end, Some(last) if last.end == new_range.start => last.end = new_range.end,
@@ -100,10 +105,10 @@ impl Blob {
written written
} }
// read time complexity, worst: O(n + m + d) // read time complexity, worst: O(n + r + d)
// where n is bytes read // where n is bytes read
// and m is ranges added. Usually O(1), but could be O(m) on re-allocation copy // where r is ranges checked for already-read bytes
// and d is arrays dropped // where d is arrays dropped
fn read(&mut self, start: usize, len: usize) -> Result<Vec<u8>, BlobError> { fn read(&mut self, start: usize, len: usize) -> Result<Vec<u8>, BlobError> {
if len == 0 { if len == 0 {
return Ok(Vec::new()); return Ok(Vec::new());
@@ -113,6 +118,8 @@ impl Blob {
return Err(BlobError::InvalidRange); return Err(BlobError::InvalidRange);
} }
// check if range is valid - O(r)
// todo: we should be able to early exit here once we've gone too far
let read_range = Range::new(start, start + len); let read_range = Range::new(start, start + len);
let Some(idx) = self.ranges.iter().position(|r| r.contains(&read_range)) else { let Some(idx) = self.ranges.iter().position(|r| r.contains(&read_range)) else {
return Err(BlobError::BytesAlreadyRead); return Err(BlobError::BytesAlreadyRead);
@@ -128,7 +135,7 @@ impl Blob {
copied += take; copied += take;
} }
// update valid ranges - worst case: O(m), usual case O(1) // update valid ranges - usually: O(1), but could re-allocate O(r)
let vr = self.ranges[idx]; let vr = self.ranges[idx];
let mut remainder = Vec::new(); let mut remainder = Vec::new();
if vr.start < read_range.start { if vr.start < read_range.start {
@@ -139,17 +146,17 @@ impl Blob {
} }
self.ranges.splice(idx..idx + 1, remainder); self.ranges.splice(idx..idx + 1, remainder);
// reclaim arrays below the lowest unread byte // reclaim arrays below the lowest unread byte - O(d)
// this will keep memory footprint relatively small // this will keep memory footprint relatively small unless reads are sparse
// at low cost O(1) or worst on re-allocation: O(number_of_arrays) // using Vec<Option<Box<...>>> would handle sparse data better for more stable addressing,
// compared to a Vec<u8> O(n) // but it leaves None slots that cannot be reclaimed as effectively
// could also use a Vec<Option<Box>>, but this would leave None slots
let min_unread = self let min_unread = self
.ranges .ranges
.first() .first()
.map(|r| r.start) .map(|r| r.start)
.unwrap_or(self.end_index); .unwrap_or(self.end_index);
while self.dropped < min_unread / ARRAY_SIZE { while self.dropped < min_unread / ARRAY_SIZE {
// note: if data is particularly sensitive we should consider zeroing the array on drop
self.arrays.pop_front(); self.arrays.pop_front();
self.dropped += 1; self.dropped += 1;
} }
@@ -167,18 +174,35 @@ enum BlobError {
} }
struct BlobManager { struct BlobManager {
// fine if we assume: // blob indexing is left up to the caller, but
// * IDs are sequentially generated // vector is fine if we assume IDs are sequentially generated
// * and IDs can be re-used // fast access: O(1)
blobs: RwLock<Vec<Option<Arc<Mutex<Blob>>>>>, // usually fast push_back: O(1), unless re-allocation: O(number_of_blobs)
// space complexity: O(number_of_blobs * blob_size)
// ------------------------
// Also note RwLock (create) starvation might be a concern if caller is // Also note RwLock (create) starvation might be a concern if caller is
// append/read heavy due to platform-defined fairness // append/read heavy due to platform-defined fairness
// todo: better approach?
// RwLock benefits shrink as create traffic becomes heavy // RwLock benefits shrink as create traffic becomes heavy
// Could address with sharding the collection into n collection shards
// and determine which shard to use with id % n
// Each shard would have it's own RwLock
// ------------------------
// If blob creation is arbitrary by ID then
// RwLock<HashMap<usize, Arc<Mutex<Blob>>>> would be a better fit.
// There wouldn't be a bunch of empty slots so we save on memory
// but we would pay a bit in access for hash computation and collision handling
// average insert: O(1), but can be long if there are many collisions O(n)
// average access: O(1), but can be long if there are many collisions O(n)
// ------------------------
// If blob IDs are created more arbitrarily
// it would probably good to provide an API that quickly pops unused IDs
// from a collection (probably VecDeque) so the user doesn't need to poll
// the create function on Error
// This would make adding blob deletion easier which could get us more
// memory space when using big numbers of blobs
blobs: RwLock<Vec<Option<Arc<Mutex<Blob>>>>>,
} }
// todo: blob indexing is left up to the caller maybe add ID generator
// for cross-thread synchronization or change approach
impl BlobManager { impl BlobManager {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@@ -191,7 +215,11 @@ impl BlobManager {
if let Some(Some(_)) = blobs.get(id) { if let Some(Some(_)) = blobs.get(id) {
return Err(BlobError::BlobExists); return Err(BlobError::BlobExists);
} else if id >= blobs.len() { } else if id >= blobs.len() {
// todo: better strategy possible? // note on resizing tradeoff: we assumed above that IDs are sequentially generated
// so it might be better to extend the vector by a larger amount all at once (say 2*id)
// if we are more interested in paying that cost upfront.
// though moving to the cost upfront would make even less sense if IDs are added arbitrarily.
// leaving as is for now
blobs.resize_with(id + 1, || None); blobs.resize_with(id + 1, || None);
} }
blobs[id] = Some(Arc::new(Mutex::new(Blob::new()))); blobs[id] = Some(Arc::new(Mutex::new(Blob::new())));
@@ -219,7 +247,8 @@ impl BlobManager {
} }
}; };
let read = blob.lock().unwrap().read(start, len)?; // propagate error to caller let read = blob.lock().unwrap().read(start, len)?; // propagate error to caller
// todo: if read clears a blob entirely should the blob be removed? // note: if read clears a blob entirely we maintain the blob for eternal blobs
// though could free up some memory if we delete when we don't need anymore
Ok(read) Ok(read)
} }
} }