use std::fs::File;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use anyhow::Context as _;
use bao_tree::io::BaoContentItem;
use bao_tree::io::fsm::{ResponseDecoder, ResponseDecoderNext};
use bao_tree::io::sync::{ReadAt, WriteAt};
use bao_tree::{BaoTree, ChunkNum, ChunkRanges};
use bytes::Bytes;
use decdn_bao_range::{AlignedRange, IROH_BLOCK_SIZE, RangedFuture, RangedStore, RangedStoreError};
use crate::sink::{StashedFault, classify_decode_error};
use crate::source::IngestEnd;
const CHECKPOINT_WAIT_LOG_FLOOR: std::time::Duration = std::time::Duration::from_secs(1);
const HASH_BUF_BYTES: usize = 1 << 20;
#[derive(Debug, Clone, PartialEq, Eq)]
struct StoreState {
bound: u64,
proven: Option<u64>,
present: ChunkRanges,
}
impl StoreState {
fn empty(bound: u64) -> Self {
Self {
bound,
proven: None,
present: ChunkRanges::empty(),
}
}
const fn prove(&mut self, size: u64) {
self.proven = Some(size);
self.bound = size;
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct Record {
bound: u64,
proven: Option<u64>,
boundaries: Vec<u64>,
}
pub struct ClientRangedStore {
root: [u8; 32],
data_path: Arc<Mutex<PathBuf>>,
ranges_path: PathBuf,
state: Arc<Mutex<StoreState>>,
present_grew: Arc<tokio::sync::Notify>,
#[cfg(test)]
fsync_delay: std::time::Duration,
#[cfg(test)]
fsyncs: Arc<std::sync::atomic::AtomicU32>,
#[cfg(test)]
checkpoint_ends: Arc<Mutex<Vec<u64>>>,
}
impl std::fmt::Debug for ClientRangedStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientRangedStore")
.field("root", &hex_prefix(&self.root))
.field("bound", &self.bound())
.field("proven", &self.proven())
.field("ranges_path", &self.ranges_path)
.finish_non_exhaustive()
}
}
fn hex_prefix(root: &[u8; 32]) -> String {
use std::fmt::Write as _;
root.iter().take(4).fold(String::new(), |mut acc, b| {
let _ = write!(acc, "{b:02x}");
acc
})
}
fn backend<E: std::error::Error + Send + Sync + 'static>(e: E) -> RangedStoreError {
RangedStoreError::Backend(Box::new(e))
}
fn lock_poisoned(what: &str) -> RangedStoreError {
RangedStoreError::Backend(format!("{what} lock poisoned").into())
}
fn empty_root() -> [u8; 32] {
*blake3::hash(&[]).as_bytes()
}
fn whole(size: u64) -> ChunkRanges {
ChunkRanges::from(ChunkNum(0)..ChunkNum::chunks(size))
}
fn sidecar_paths(dir: &Path, stem: &str) -> (PathBuf, PathBuf) {
let data = dir.join(format!("{stem}.partial"));
let ranges = dir.join(format!("{stem}.partial.ranges"));
(data, ranges)
}
fn hash_prefix(file: &File, len: u64) -> io::Result<[u8; 32]> {
let mut hasher = blake3::Hasher::new();
let mut buf = vec![0u8; HASH_BUF_BYTES];
let mut pos = 0u64;
while pos < len {
let want = usize::try_from(len.saturating_sub(pos))
.map_or(HASH_BUF_BYTES, |left| left.min(HASH_BUF_BYTES));
let slice = buf
.get_mut(..want)
.ok_or_else(|| io::Error::other("hash buffer slice"))?;
file.read_exact_at(pos, slice)?;
hasher.update(slice);
pos = pos.saturating_add(u64::try_from(want).map_err(io::Error::other)?);
}
Ok(*hasher.finalize().as_bytes())
}
fn prefix_matches(file: &File, len: u64, root: [u8; 32]) -> io::Result<bool> {
match hash_prefix(file, len) {
Ok(hash) => Ok(hash == root),
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => Ok(false),
Err(e) => Err(e),
}
}
fn write_record(path: &Path, state: &StoreState) -> io::Result<()> {
let record = Record {
bound: state.bound,
proven: state.proven,
boundaries: state.present.boundaries().iter().map(|c| c.0).collect(),
};
let json =
serde_json::to_vec(&record).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let dir = path.parent().unwrap_or_else(|| Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
io::Write::write_all(&mut tmp, &json)?;
tmp.as_file().sync_all()?;
tmp.persist(path)
.map_err(|e| io::Error::new(e.error.kind(), e.error))?;
Ok(())
}
fn read_record(path: &Path) -> io::Result<StoreState> {
let bytes = std::fs::read(path)?;
let Record {
bound,
proven,
boundaries,
} = serde_json::from_slice(&bytes).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("unrecognised record format at {}: {e}", path.display()),
)
})?;
if !boundaries.len().is_multiple_of(2) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"present-range record at {} has an odd boundary count ({})",
path.display(),
boundaries.len()
),
));
}
let mut acc = ChunkRanges::empty();
let mut it = boundaries.into_iter();
while let Some(a) = it.next() {
let Some(b) = it.next() else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"present-range record at {} is truncated mid-pair",
path.display()
),
));
};
if a > b {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"present-range record at {} has a decreasing pair ({a}, {b})",
path.display()
),
));
}
acc |= ChunkRanges::from(ChunkNum(a)..ChunkNum(b));
}
Ok(StoreState {
bound,
proven,
present: acc,
})
}
impl ClientRangedStore {
fn with_state(
root: [u8; 32],
data_path: PathBuf,
ranges_path: PathBuf,
mut state: StoreState,
) -> Self {
if root == empty_root() {
state.prove(0);
}
Self {
root,
data_path: Arc::new(Mutex::new(data_path)),
ranges_path,
state: Arc::new(Mutex::new(state)),
present_grew: Arc::default(),
#[cfg(test)]
fsync_delay: std::time::Duration::ZERO,
#[cfg(test)]
fsyncs: Arc::default(),
#[cfg(test)]
checkpoint_ends: Arc::default(),
}
}
pub fn create(dir: &Path, stem: &str, root: [u8; 32], bound_hint: u64) -> io::Result<Self> {
let (data_path, ranges_path) = sidecar_paths(dir, stem);
File::create(&data_path)?;
let store = Self::with_state(root, data_path, ranges_path, StoreState::empty(bound_hint));
write_record(&store.ranges_path, &store.snapshot())?;
Ok(store)
}
pub fn open(dir: &Path, stem: &str, root: [u8; 32]) -> io::Result<Self> {
let (data_path, ranges_path) = sidecar_paths(dir, stem);
let final_path = dir.join(stem);
if final_path.exists() {
let len = std::fs::metadata(&final_path)?.len();
if prefix_matches(&File::open(&final_path)?, len, root)? {
let _ = std::fs::remove_file(&ranges_path);
let state = StoreState {
bound: len,
proven: Some(len),
present: whole(len),
};
return Ok(Self::with_state(root, final_path, ranges_path, state));
}
}
let state = read_record(&ranges_path)?;
Ok(Self::with_state(root, data_path, ranges_path, state))
}
pub fn has_record(dir: &Path, stem: &str) -> io::Result<bool> {
let (_data_path, ranges_path) = sidecar_paths(dir, stem);
ranges_path.try_exists()
}
pub fn open_or_create(
dir: &Path,
stem: &str,
root: [u8; 32],
bound_hint: u64,
) -> io::Result<Self> {
let (_data_path, ranges_path) = sidecar_paths(dir, stem);
if ranges_path.try_exists()? {
Self::open(dir, stem, root)
} else {
Self::create(dir, stem, root, bound_hint)
}
}
#[cfg(any(test, feature = "test-util"))]
pub fn seed_checkpointed_prefix(
dir: &Path,
stem: &str,
blob: &[u8],
prefix_len: u64,
) -> io::Result<()> {
let total_bytes = u64::try_from(blob.len())
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
let (data_path, ranges_path) = sidecar_paths(dir, stem);
let aligned = decdn_bao_range::align_range(0, prefix_len, total_bytes)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
let end = usize::try_from(aligned.fetch_end())
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
let prefix = blob.get(..end).ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "aligned prefix end past blob")
})?;
std::fs::write(&data_path, prefix)?;
let mut state = StoreState::empty(total_bytes);
state.present = aligned.chunk_ranges().clone();
if aligned.fetch_end() == total_bytes {
state.prove(total_bytes);
}
write_record(&ranges_path, &state)
}
#[must_use]
pub const fn root(&self) -> [u8; 32] {
self.root
}
#[must_use]
pub fn bound(&self) -> u64 {
self.lock_state().bound
}
#[must_use]
pub fn proven(&self) -> Option<u64> {
self.lock_state().proven
}
#[must_use]
pub fn present_byte_ranges(&self) -> Vec<(u64, u64)> {
let state = self.snapshot();
crate::driver::contiguous_byte_ranges(&state.present, state.bound)
}
#[must_use]
pub(crate) fn present_grew(&self) -> &tokio::sync::Notify {
&self.present_grew
}
pub fn set_bound(&self, bound: u64) {
let mut state = self.lock_state();
if state.proven.is_none() {
state.bound = bound;
}
}
fn lock_state(&self) -> std::sync::MutexGuard<'_, StoreState> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
#[cfg(test)]
#[must_use]
pub(crate) fn ranges_path(&self) -> &Path {
&self.ranges_path
}
fn snapshot(&self) -> StoreState {
self.lock_state().clone()
}
pub(crate) const INGEST_CHECKPOINT_BYTES: u64 = 4 * 1024 * 1024;
pub(crate) const INGEST_FIRST_CHECKPOINT_BYTES: u64 = 64 * 1024;
pub(crate) const fn next_checkpoint_bytes(current: u64) -> u64 {
let doubled = current.saturating_mul(2);
if doubled < Self::INGEST_CHECKPOINT_BYTES {
doubled
} else {
Self::INGEST_CHECKPOINT_BYTES
}
}
#[cfg(test)]
pub(crate) const fn checkpointed_len(received: u64) -> u64 {
let mut size = Self::INGEST_FIRST_CHECKPOINT_BYTES;
let mut end = 0;
while end + size <= received {
end += size;
size = Self::next_checkpoint_bytes(size);
}
end
}
pub(crate) const INGEST_MAX_QUEUED_CHECKPOINTS: usize = 3;
pub async fn ingest_stream<R>(
&self,
range: &AlignedRange,
reader: R,
on_progress: Option<&(dyn Fn(u64) + Send + Sync)>,
claimed_total: u64,
) -> anyhow::Result<R>
where
R: iroh_io::AsyncStreamReader + StashedFault + Send,
{
self.ingest_stream_until(range, reader, on_progress, claimed_total, None)
.await
.map(|(reader, _)| reader)
}
pub(crate) async fn ingest_stream_until<R>(
&self,
range: &AlignedRange,
reader: R,
on_progress: Option<&(dyn Fn(u64) + Send + Sync)>,
claimed_total: u64,
stop_at: Option<&AtomicU64>,
) -> anyhow::Result<(R, IngestEnd)>
where
R: iroh_io::AsyncStreamReader + StashedFault + Send,
{
let range = &decdn_bao_range::align_range_clamped(
range.fetch_start(),
range.fetch_len(),
claimed_total,
)
.map_err(|e| {
anyhow::anyhow!(
"the leg claims a {claimed_total}-byte blob, which does not cover its range \
start {}: {e}",
range.fetch_start()
)
})?;
if claimed_total == 0 {
if self.root != empty_root() {
return Err(anyhow::Error::new(crate::HashMismatch));
}
self.lock_state().prove(0);
return Ok((reader, IngestEnd::Drained));
}
let mut flusher = IngestFlusher::open(self, range, claimed_total).await?;
let root = bao_tree::blake3::Hash::from(self.root);
let ranges = range.chunk_ranges().clone();
let tree = BaoTree::new(claimed_total, IROH_BLOCK_SIZE);
let mut decoder = ResponseDecoder::new(root, ranges, tree, reader);
let mut received_end = range.fetch_start();
let mut batch_start = range.fetch_start();
let mut batch = FlushBatch::default();
let mut batch_target = Self::INGEST_FIRST_CHECKPOINT_BYTES;
loop {
match decoder.next().await {
ResponseDecoderNext::More((rest, Ok(BaoContentItem::Leaf(leaf)))) => {
received_end =
received_end
.max(leaf.offset.saturating_add(
u64::try_from(leaf.data.len()).unwrap_or(u64::MAX),
));
batch.leaves.push((leaf.offset, leaf.data));
if let Some(cb) = on_progress {
cb(received_end.saturating_sub(range.fetch_start()));
}
if received_end.saturating_sub(batch_start) >= batch_target {
flusher
.start(std::mem::take(&mut batch), received_end)
.await?;
batch_start = received_end;
batch_target = Self::next_checkpoint_bytes(batch_target);
}
if received_end < range.fetch_end()
&& stop_at.is_some_and(|end| received_end >= end.load(Ordering::SeqCst))
{
let r = rest.finish();
flusher.finish(batch, received_end).await?;
return Ok((r, IngestEnd::Stopped));
}
decoder = rest;
}
ResponseDecoderNext::More((rest, Ok(BaoContentItem::Parent(_)))) => {
decoder = rest;
}
ResponseDecoderNext::More((rest, Err(decode_err))) => {
let mut r = rest.finish();
if let Err(flush_err) = flusher.finish(batch, received_end).await {
warn_flush_failed_on_fault(&flush_err, range, received_end);
}
if let Some(fault) = r.take_fault() {
return Err(fault);
}
return Err(classify_decode_error(decode_err));
}
ResponseDecoderNext::Done(mut r) => {
let flush_result = flusher.finish(batch, received_end).await;
if let Some(fault) = r.take_fault() {
if let Err(flush_err) = flush_result {
warn_flush_failed_on_fault(&flush_err, range, received_end);
}
return Err(fault);
}
flush_result?;
return Ok((r, IngestEnd::Drained));
}
}
}
}
fn write_record_blocking(
&self,
) -> impl std::future::Future<Output = io::Result<()>> + Send + 'static {
let snapshot = self.snapshot();
let ranges_path = self.ranges_path.clone();
async move {
tokio::task::spawn_blocking(move || write_record(&ranges_path, &snapshot))
.await
.map_err(io::Error::other)?
}
}
pub fn flush_present_record(&self) -> io::Result<()> {
write_record(&self.ranges_path, &self.snapshot())
}
}
impl ClientRangedStore {
pub(crate) const fn flush_on_drop(&self) -> FlushOnDrop<'_> {
FlushOnDrop { store: Some(self) }
}
}
pub(crate) struct FlushOnDrop<'a> {
store: Option<&'a ClientRangedStore>,
}
impl FlushOnDrop<'_> {
pub(crate) fn disarm(mut self) {
self.store = None;
}
}
impl Drop for FlushOnDrop<'_> {
fn drop(&mut self) {
if let Some(store) = self.store.take()
&& let Err(error) = store.flush_present_record()
{
tracing::warn!(
%error,
"could not persist the present-range record of a dropped fetch; its \
unrecorded ranges are fetched again on resume"
);
}
}
}
#[derive(Default)]
struct FlushBatch {
leaves: Vec<(u64, Bytes)>,
}
impl FlushBatch {
const fn is_empty(&self) -> bool {
self.leaves.is_empty()
}
}
struct Checkpoint {
batch: FlushBatch,
received_end: u64,
slot: tokio::sync::OwnedSemaphorePermit,
}
struct IngestFlusher {
pipeline: Arc<IngestPipeline>,
slots: Arc<tokio::sync::Semaphore>,
worker: Option<tokio::task::JoinHandle<anyhow::Result<()>>>,
#[cfg(test)]
checkpoint_ends: Arc<Mutex<Vec<u64>>>,
}
impl IngestFlusher {
async fn open(
store: &ClientRangedStore,
range: &AlignedRange,
claim: u64,
) -> anyhow::Result<Self> {
let path = store
.data_path
.lock()
.map_err(|_| anyhow::Error::new(lock_poisoned("data_path")))?
.clone();
let data = tokio::task::spawn_blocking(move || {
std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&path)
})
.await??;
let pipeline = IngestPipeline {
state: Mutex::new(PipelineState::default()),
data: Mutex::new(data),
store: Arc::clone(&store.state),
present_grew: Arc::clone(&store.present_grew),
claim,
range: range.clone(),
#[cfg(test)]
fsync_delay: store.fsync_delay,
#[cfg(test)]
fsyncs: Arc::clone(&store.fsyncs),
};
Ok(Self {
pipeline: Arc::new(pipeline),
slots: Arc::new(tokio::sync::Semaphore::new(
ClientRangedStore::INGEST_MAX_QUEUED_CHECKPOINTS,
)),
worker: None,
#[cfg(test)]
checkpoint_ends: Arc::clone(&store.checkpoint_ends),
})
}
async fn start(&mut self, batch: FlushBatch, received_end: u64) -> anyhow::Result<()> {
#[cfg(test)]
if let Ok(mut ends) = self.checkpoint_ends.lock() {
ends.push(received_end);
}
let waiting_since = tokio::time::Instant::now();
let slot = Arc::clone(&self.slots)
.acquire_owned()
.await
.map_err(|e| anyhow::anyhow!("ingest checkpoint slots closed: {e}"))?;
let waited = waiting_since.elapsed();
if waited >= CHECKPOINT_WAIT_LOG_FLOOR {
tracing::debug!(
received_end,
waited_ms = waited.as_millis(),
"ingest waited on a checkpoint slot"
);
}
let start_worker = {
let mut state = self.pipeline.lock_state()?;
if state.failed {
None
} else {
state.queue.push_back(Checkpoint {
batch,
received_end,
slot,
});
Some(!std::mem::replace(&mut state.worker_running, true))
}
};
match start_worker {
Some(true) => {
let pipeline = Arc::clone(&self.pipeline);
self.worker = Some(tokio::task::spawn_blocking(move || pipeline.run()));
Ok(())
}
Some(false) => Ok(()),
None => match self.join().await {
Err(e) => Err(e),
Ok(()) => Err(anyhow::anyhow!("ingest checkpoint worker failed")),
},
}
}
async fn finish(mut self, batch: FlushBatch, received_end: u64) -> anyhow::Result<()> {
if !batch.is_empty() {
self.start(batch, received_end).await?;
}
self.join().await
}
async fn join(&mut self) -> anyhow::Result<()> {
match self.worker.take() {
Some(handle) => handle
.await
.map_err(|e| anyhow::anyhow!("ingest checkpoint worker failed: {e}"))?,
None => Ok(()),
}
}
}
struct IngestPipeline {
state: Mutex<PipelineState>,
data: Mutex<File>,
store: Arc<Mutex<StoreState>>,
present_grew: Arc<tokio::sync::Notify>,
claim: u64,
range: AlignedRange,
#[cfg(test)]
fsync_delay: std::time::Duration,
#[cfg(test)]
fsyncs: Arc<std::sync::atomic::AtomicU32>,
}
#[derive(Default)]
struct PipelineState {
queue: std::collections::VecDeque<Checkpoint>,
worker_running: bool,
failed: bool,
}
impl IngestPipeline {
fn lock_state(&self) -> anyhow::Result<std::sync::MutexGuard<'_, PipelineState>> {
self.state
.lock()
.map_err(|_| anyhow::Error::new(lock_poisoned("ingest pipeline")))
}
fn run(self: Arc<Self>) -> anyhow::Result<()> {
let mut exit = WorkerExit {
pipeline: &self,
clean: false,
};
let res = self.drain();
match &res {
Ok(()) => exit.clean = true,
Err(e) => tracing::warn!(
error = %e,
fetch_start = self.range.fetch_start(),
fetch_end = self.range.fetch_end(),
"ingest checkpoint failed"
),
}
drop(exit);
res
}
fn drain(&self) -> anyhow::Result<()> {
let cap = ClientRangedStore::INGEST_MAX_QUEUED_CHECKPOINTS;
let mut data = self
.data
.lock()
.map_err(|_| anyhow::Error::new(lock_poisoned("ingest data file")))?;
let mut slots = Vec::with_capacity(cap);
while let Some(first) = self.pop(true)? {
let mut received_end = write_checkpoint(&mut data, first, &mut slots)?;
while slots.len() < cap {
let Some(next) = self.pop(false)? else {
break;
};
received_end = received_end.max(write_checkpoint(&mut data, next, &mut slots)?);
}
#[cfg(test)]
{
std::thread::sleep(self.fsync_delay);
self.fsyncs
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
sync_and_union(&data, &self.store, self.claim, &self.range, received_end)?;
self.present_grew.notify_waiters();
slots.clear();
}
Ok(())
}
fn pop(&self, retire: bool) -> anyhow::Result<Option<Checkpoint>> {
let mut state = self.lock_state()?;
let next = state.queue.pop_front();
if next.is_none() && retire {
state.worker_running = false;
}
Ok(next)
}
}
struct WorkerExit<'a> {
pipeline: &'a IngestPipeline,
clean: bool,
}
impl Drop for WorkerExit<'_> {
fn drop(&mut self) {
if self.clean {
return;
}
let mut state = self
.pipeline
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.failed = true;
state.worker_running = false;
state.queue.clear();
}
}
fn write_checkpoint(
data: &mut File,
checkpoint: Checkpoint,
slots: &mut Vec<tokio::sync::OwnedSemaphorePermit>,
) -> anyhow::Result<u64> {
let Checkpoint {
batch,
received_end,
slot,
} = checkpoint;
write_batch(data, &batch)?;
slots.push(slot);
Ok(received_end)
}
fn warn_flush_failed_on_fault(err: &anyhow::Error, range: &AlignedRange, received_end: u64) {
tracing::warn!(
error = %err,
fetch_start = range.fetch_start(),
received_end,
"ingest checkpoint failed while handling a stream fault"
);
}
fn write_batch(data: &mut File, batch: &FlushBatch) -> anyhow::Result<()> {
for (offset, bytes) in &batch.leaves {
data.write_all_at(*offset, bytes)
.with_context(|| format!("writing .partial data at offset {offset}"))?;
}
Ok(())
}
fn sync_and_union(
data: &File,
store: &Mutex<StoreState>,
claim: u64,
range: &AlignedRange,
received_end: u64,
) -> anyhow::Result<()> {
data.sync_data().context("fsyncing .partial data")?;
if received_end <= range.fetch_start() {
return Ok(());
}
let received = decdn_bao_range::align_range(
range.fetch_start(),
received_end.saturating_sub(range.fetch_start()),
claim,
)?;
let mut state = store.lock().unwrap_or_else(PoisonError::into_inner);
state.present |= received.chunk_ranges().clone();
if received_end >= claim {
if let Some(earlier) = state.proven.filter(|&p| p != claim) {
tracing::warn!(
earlier_proven = earlier,
proven = claim,
"a verified final chunk proves another size than the store held"
);
}
state.prove(claim);
}
Ok(())
}
struct AdmitBody(Bytes);
impl iroh_io::AsyncStreamReader for AdmitBody {
async fn read_bytes(&mut self, len: usize) -> io::Result<Bytes> {
self.0.read_bytes(len).await
}
async fn read<const L: usize>(&mut self) -> io::Result<[u8; L]> {
self.0.read::<L>().await
}
}
impl StashedFault for AdmitBody {
fn take_fault(&mut self) -> Option<anyhow::Error> {
None
}
}
impl RangedStore for ClientRangedStore {
fn total_bytes(&self) -> u64 {
self.bound()
}
fn present_ranges(&self) -> RangedFuture<'_, ChunkRanges> {
Box::pin(async move { Ok(self.snapshot().present) })
}
fn missing_ranges(&self, byte_offset: u64, byte_len: u64) -> RangedFuture<'_, ChunkRanges> {
Box::pin(async move {
let state = self.snapshot();
let aligned = decdn_bao_range::align_range_clamped(byte_offset, byte_len, state.bound)?;
Ok(aligned.chunk_ranges().clone() - &state.present)
})
}
fn admit(&self, range: AlignedRange, bao_bytes: Bytes) -> RangedFuture<'_, ()> {
Box::pin(async move {
let short = || {
RangedStoreError::Backend(
"admit: bao_bytes shorter than the 8-byte combined-format header".into(),
)
};
let header: [u8; 8] = bao_bytes
.get(..8)
.and_then(|h| h.try_into().ok())
.ok_or_else(short)?;
let claim = u64::from_le_bytes(header);
let body = bao_bytes.slice(8..);
self.ingest_stream(&range, AdmitBody(body), None, claim)
.await
.map_err(|e| RangedStoreError::Backend(e.into()))?;
self.write_record_blocking().await.map_err(backend)
})
}
fn read(&self, byte_offset: u64, byte_len: u64) -> RangedFuture<'_, Bytes> {
Box::pin(async move {
let state = self.snapshot();
let aligned = decdn_bao_range::align_range_clamped(byte_offset, byte_len, state.bound)?;
if !(aligned.chunk_ranges().clone() - &state.present).is_empty() {
return Err(RangedStoreError::Backend(
"requested range not present".into(),
));
}
let read_start = byte_offset;
let room = state.bound.saturating_sub(byte_offset);
let read_len = if byte_len == 0 {
room
} else {
byte_len.min(room)
};
let data_path = Arc::clone(&self.data_path);
tokio::task::spawn_blocking(move || -> Result<Bytes, RangedStoreError> {
let path = data_path.lock().map_err(|_| lock_poisoned("data_path"))?;
let file = File::open(&*path).map_err(backend)?;
let len = usize::try_from(read_len).map_err(backend)?;
let mut buf = vec![0u8; len];
if len > 0 {
file.read_exact_at(read_start, &mut buf).map_err(backend)?;
}
Ok(Bytes::from(buf))
})
.await
.map_err(backend)?
})
}
fn is_complete(&self) -> RangedFuture<'_, bool> {
Box::pin(async move {
let state = self.snapshot();
Ok(state
.proven
.is_some_and(|proven| (whole(proven) - &state.present).is_empty()))
})
}
fn finalize(&self) -> RangedFuture<'_, ()> {
Box::pin(async move {
let current = {
let guard = self
.data_path
.lock()
.map_err(|_| lock_poisoned("data_path"))?;
guard.clone()
};
let is_partial = current
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with(".partial"));
if !is_partial {
return Ok(());
}
let state = self.snapshot();
let Some(proven) = state.proven else {
return Err(RangedStoreError::Incomplete);
};
if !(whole(proven) - &state.present).is_empty() {
return Err(RangedStoreError::Incomplete);
}
self.write_record_blocking().await.map_err(backend)?;
let root = self.root;
let ranges_path = self.ranges_path.clone();
let data_path = Arc::clone(&self.data_path);
let promoted =
tokio::task::spawn_blocking(move || -> Result<bool, RangedStoreError> {
let current_path = data_path
.lock()
.map_err(|_| lock_poisoned("data_path"))?
.clone();
let data_file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(¤t_path)
.map_err(backend)?;
if !prefix_matches(&data_file, proven, root).map_err(backend)? {
return Ok(false);
}
let file_name = current_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| {
RangedStoreError::Backend("data path has no file name".into())
})?;
let final_name = file_name.strip_suffix(".partial").ok_or_else(|| {
RangedStoreError::Backend("data path is not a .partial file".into())
})?;
let final_path = current_path.with_file_name(final_name);
if data_file.metadata().map_err(backend)?.len() > proven {
data_file.set_len(proven).map_err(backend)?;
}
data_file.sync_all().map_err(backend)?;
drop(data_file);
std::fs::rename(¤t_path, &final_path).map_err(backend)?;
{
let mut guard = data_path.lock().map_err(|_| lock_poisoned("data_path"))?;
*guard = final_path;
}
let _ = std::fs::remove_file(&ranges_path);
Ok(true)
})
.await
.map_err(backend)??;
if promoted {
return Ok(());
}
{
let mut state = self.lock_state();
*state = StoreState::empty(state.bound);
}
self.write_record_blocking().await.map_err(backend)?;
Err(RangedStoreError::Backend(Box::new(crate::HashMismatch)))
})
}
}
impl crate::source::IngestStore for ClientRangedStore {
fn ingest_stream<'a, R>(
&'a self,
range: &'a AlignedRange,
reader: R,
on_progress: Option<&'a (dyn Fn(u64) + Send + Sync)>,
claimed_total: u64,
stop_at: Option<&'a AtomicU64>,
) -> crate::source::IngestFuture<'a, R>
where
R: crate::source::BaoRangeReader + 'a,
{
Box::pin(self.ingest_stream_until(range, reader, on_progress, claimed_total, stop_at))
}
fn flush_present_record(&self) -> crate::source::SourceFuture<'_, ()> {
let write = self.write_record_blocking();
Box::pin(async move { Ok(write.await?) })
}
fn proven(&self) -> Option<u64> {
Self::proven(self)
}
fn set_bound(&self, bound: u64) {
Self::set_bound(self, bound);
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)] mod tests;