use std::{
fmt::Debug,
fs::{File, create_dir_all, remove_dir_all, remove_file},
io::{BufReader, BufWriter, Read, Write},
marker::PhantomData,
path::{Path, PathBuf},
sync::atomic::{AtomicUsize, Ordering},
};
use anyhow::Result;
use dashmap::DashMap;
use futures::{Stream, StreamExt, stream::BoxStream};
use uuid::Uuid;
use versatiles_derive::context;
use crate::cache::{cache_type::CacheType, traits::CacheValue};
struct CountingReader<R> {
inner: R,
position: u64,
}
impl<R: Read> Read for CountingReader<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let n = self.inner.read(buf)?;
self.position += n as u64;
Ok(n)
}
}
struct DirCleanupGuard(PathBuf);
impl Drop for DirCleanupGuard {
fn drop(&mut self) {
let _ = remove_dir_all(&self.0);
}
}
pub enum TraversalCache<V: CacheValue> {
Memory(DashMap<usize, Vec<V>>),
Disk {
path: PathBuf,
next_writer_id: AtomicUsize,
file_index: DashMap<usize, Vec<PathBuf>>,
_marker: PhantomData<V>,
},
}
impl<V: CacheValue> TraversalCache<V> {
pub fn new(cache_type: &CacheType) -> Result<Self> {
Ok(match cache_type {
CacheType::InMemory => Self::Memory(DashMap::new()),
CacheType::Disk(base_path) => {
let path = base_path.join(format!("traversal_{}", Uuid::new_v4()));
create_dir_all(&path)?;
Self::Disk {
path,
next_writer_id: AtomicUsize::new(0),
file_index: DashMap::new(),
_marker: PhantomData,
}
}
})
}
#[context("Failed to append stream to traversal cache at index {}", index)]
pub async fn append_stream<S>(&self, index: usize, mut stream: S) -> Result<()>
where
S: Stream<Item = V> + Send + Unpin,
{
match self {
Self::Memory(map) => {
while let Some(value) = stream.next().await {
map.entry(index).or_default().push(value);
}
Ok(())
}
Self::Disk {
path,
next_writer_id,
file_index,
..
} => {
let (mut writer, file_path) = Self::create_cache_file(path, index, next_writer_id)?;
while let Some(value) = stream.next().await {
value.write_to_cache(&mut writer)?;
}
writer.flush()?;
file_index.entry(index).or_default().push(file_path);
Ok(())
}
}
}
#[context("Failed to take stream from traversal cache at index {}", index)]
pub fn take_stream(&self, index: usize) -> Result<BoxStream<'static, V>>
where
V: Send + 'static,
{
match self {
Self::Memory(map) => Ok(map.remove(&index).map_or_else(
|| futures::stream::empty().boxed(),
|(_, v)| futures::stream::iter(v).boxed(),
)),
Self::Disk { path, file_index, .. } => {
let Some(files) = Self::take_index_files(file_index, index) else {
return Ok(futures::stream::empty().boxed());
};
let dir_path = path.join(index.to_string());
let (tx, rx) = tokio::sync::mpsc::channel::<V>(64);
for file_path in files {
let tx = tx.clone();
tokio::task::spawn_blocking(move || {
match Self::iter_values_from_file(&file_path) {
Err(e) => log::warn!("failed to open cache file {}: {e}", file_path.display()),
Ok(iter) => {
for result in iter {
match result {
Ok(value) => {
if tx.blocking_send(value).is_err() {
break;
}
}
Err(e) => {
log::warn!(
"failed to deserialize value from cache file {}: {e}",
file_path.display()
);
break;
}
}
}
}
}
let _ = remove_file(&file_path);
});
}
drop(tx);
let guard = DirCleanupGuard(dir_path);
let stream = futures::stream::unfold((rx, guard), |(mut rx, guard)| async move {
rx.recv().await.map(|v| (v, (rx, guard)))
});
Ok(stream.boxed())
}
}
}
fn create_cache_file(path: &Path, index: usize, next_writer_id: &AtomicUsize) -> Result<(BufWriter<File>, PathBuf)> {
let writer_id = next_writer_id.fetch_add(1, Ordering::Relaxed);
let dir_path = path.join(index.to_string());
create_dir_all(&dir_path)?;
let file_path = dir_path.join(format!("{writer_id:012}.bin"));
let file = File::create(&file_path)?;
Ok((BufWriter::new(file), file_path))
}
fn take_index_files(file_index: &DashMap<usize, Vec<PathBuf>>, index: usize) -> Option<Vec<PathBuf>> {
match file_index.remove(&index) {
Some((_, files)) if !files.is_empty() => Some(files),
_ => None,
}
}
fn iter_values_from_file(path: &Path) -> Result<impl Iterator<Item = Result<V>> + use<V>> {
let file = File::open(path)?;
let file_len = file.metadata()?.len();
let mut reader = CountingReader {
inner: BufReader::new(file),
position: 0,
};
Ok(std::iter::from_fn(move || {
if reader.position >= file_len {
return None;
}
Some(V::read_from_cache(&mut reader))
}))
}
fn clean_up(&self) {
match self {
Self::Memory(map) => map.clear(),
Self::Disk { path, .. } => {
remove_dir_all(path).ok();
}
}
}
}
impl<V: CacheValue> Drop for TraversalCache<V> {
fn drop(&mut self) {
self.clean_up();
}
}
impl<V: CacheValue> Debug for TraversalCache<V> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Memory(map) => {
write!(f, "TraversalCache::Memory({} entries)", map.len())
}
Self::Disk { path, .. } => {
write!(f, "TraversalCache::Disk({})", path.display())
}
}
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use tempfile::TempDir;
use super::*;
#[rstest]
#[case::mem("mem")]
#[case::disk("disk")]
#[tokio::test]
async fn test_append_and_take_stream(#[case] case: &str) -> Result<()> {
use futures::StreamExt;
let temp_dir = TempDir::new()?;
let cache_type = match case {
"mem" => CacheType::InMemory,
"disk" => CacheType::Disk(temp_dir.path().to_path_buf()),
_ => panic!("unknown case"),
};
let cache = TraversalCache::<String>::new(&cache_type)?;
let empty: Vec<String> = cache.take_stream(0)?.collect().await;
assert!(empty.is_empty());
let empty: Vec<String> = cache.take_stream(1)?.collect().await;
assert!(empty.is_empty());
cache
.append_stream(0, futures::stream::iter(vec!["a".to_string(), "b".to_string()]))
.await?;
cache
.append_stream(0, futures::stream::iter(vec!["c".to_string()]))
.await?;
cache
.append_stream(1, futures::stream::iter(vec!["x".to_string()]))
.await?;
let mut collected: Vec<String> = cache.take_stream(0)?.collect().await;
collected.sort();
assert_eq!(collected, vec!["a".to_string(), "b".to_string(), "c".to_string()]);
let empty: Vec<String> = cache.take_stream(0)?.collect().await;
assert!(empty.is_empty());
let collected1: Vec<String> = cache.take_stream(1)?.collect().await;
assert_eq!(collected1, vec!["x".to_string()]);
let empty: Vec<String> = cache.take_stream(99)?.collect().await;
assert!(empty.is_empty());
Ok(())
}
#[rstest]
#[case::mem("mem")]
#[case::disk("disk")]
#[tokio::test]
async fn test_binary_values_stream(#[case] case: &str) -> Result<()> {
use futures::StreamExt;
let temp_dir = TempDir::new()?;
let cache_type = match case {
"mem" => CacheType::InMemory,
"disk" => CacheType::Disk(temp_dir.path().to_path_buf()),
_ => panic!("unknown case"),
};
let cache = TraversalCache::<Vec<u8>>::new(&cache_type)?;
cache
.append_stream(0, futures::stream::iter(vec![vec![0, 1, 2], vec![255, 254]]))
.await?;
cache.append_stream(0, futures::stream::iter(vec![vec![128]])).await?;
let mut collected: Vec<Vec<u8>> = cache.take_stream(0)?.collect().await;
collected.sort();
assert_eq!(collected, vec![vec![0, 1, 2], vec![128], vec![255, 254]]);
Ok(())
}
#[test]
fn test_debug_format() {
let mem_cache = TraversalCache::<String>::new(&CacheType::InMemory).unwrap();
assert!(format!("{mem_cache:?}").contains("Memory"));
let tmp = TempDir::new().unwrap();
let disk_cache = TraversalCache::<String>::new(&CacheType::Disk(tmp.path().to_path_buf())).unwrap();
assert!(format!("{disk_cache:?}").contains("Disk"));
}
}