Skip to main content

ab_farmer/
disk_piece_cache.rs

1//! Disk piece cache implementation
2
3mod metrics;
4// TODO: Not supported under Miri: https://github.com/rust-lang/miri/issues/5137
5#[cfg(not(miri))]
6#[cfg(test)]
7mod tests;
8
9use crate::disk_piece_cache::metrics::DiskPieceCacheMetrics;
10use crate::farm;
11use crate::farm::{FarmError, PieceCacheId, PieceCacheOffset};
12use crate::single_disk_farm::direct_io_file_wrapper::DirectIoFileWrapper;
13use crate::utils::AsyncJoinOnDrop;
14use ab_core_primitives::hashes::Blake3Hash;
15use ab_core_primitives::pieces::{Piece, PieceIndex};
16use ab_direct_io_file::AlignedPage;
17use ab_farmer_components::file_ext::FileExt;
18use async_trait::async_trait;
19use bytes::BytesMut;
20use futures::channel::mpsc;
21use futures::{SinkExt, Stream, StreamExt, stream};
22use parking_lot::Mutex;
23use prometheus_client::registry::Registry;
24use std::num::NonZeroU32;
25use std::path::Path;
26use std::sync::atomic::{AtomicU8, Ordering};
27use std::task::Poll;
28use std::{fs, io, iter};
29use thiserror::Error;
30use tokio::runtime::Handle;
31use tokio::task;
32use tracing::{Span, debug, info, warn};
33use triomphe::Arc;
34
35/// How many pieces should be skipped before stopping to check the rest of contents, this allows to
36/// not miss most of the pieces after one or two corrupted pieces
37const CONTENTS_READ_SKIP_LIMIT: usize = 3;
38/// How many piece to read from disk at the same time (using tokio thread pool)
39const PIECES_READING_CONCURRENCY: usize = 32;
40/// Size of a cache element: piece index, piece and checksum, followed by zero padding to whole
41/// disk pages, such that elements can be written without read-modify-write
42const ELEMENT_SIZE: usize =
43    (PieceIndex::SIZE + Piece::SIZE + Blake3Hash::SIZE).next_multiple_of(AlignedPage::SIZE);
44
45/// Disk piece cache open error
46#[derive(Debug, Error)]
47pub enum DiskPieceCacheError {
48    /// I/O error occurred
49    #[error("Disk piece cache I/O error: {0}")]
50    Io(#[from] io::Error),
51    /// Can't preallocate cache file, probably not enough space on disk
52    #[error("Can't preallocate cache file, probably not enough space on disk: {0}")]
53    CantPreallocateCacheFile(io::Error),
54    /// Offset outsize of range
55    #[error("Offset outsize of range: provided {provided}, max {max}")]
56    OffsetOutsideOfRange {
57        /// Provided offset
58        provided: u32,
59        /// Max offset
60        max: u32,
61    },
62    /// Checksum mismatch
63    #[error("Checksum mismatch")]
64    ChecksumMismatch,
65}
66
67#[derive(Debug)]
68struct FilePool {
69    files: Box<[DirectIoFileWrapper; PIECES_READING_CONCURRENCY]>,
70    cursor: AtomicU8,
71}
72
73impl FilePool {
74    fn open(path: &Path) -> io::Result<Self> {
75        let files = iter::repeat_with(|| DirectIoFileWrapper::open(path))
76            .take(PIECES_READING_CONCURRENCY)
77            .collect::<Result<Box<_>, _>>()?
78            .try_into()
79            .expect("Statically correct length; qed");
80        Ok(Self {
81            files,
82            cursor: AtomicU8::new(0),
83        })
84    }
85
86    fn read(&self) -> &DirectIoFileWrapper {
87        let position = usize::from(self.cursor.fetch_add(1, Ordering::Relaxed));
88        &self.files[position % PIECES_READING_CONCURRENCY]
89    }
90
91    fn write(&self) -> &DirectIoFileWrapper {
92        // Always the same file or else overlapping writes will be corrupted due to
93        // read/modify/write internals, which are in turn caused by alignment requirements
94        &self.files[0]
95    }
96}
97
98#[derive(Debug)]
99struct Inner {
100    id: PieceCacheId,
101    files: FilePool,
102    max_num_elements: u32,
103    metrics: Option<DiskPieceCacheMetrics>,
104}
105
106/// Dedicated piece cache stored on one disk, is used both to accelerate DSN queries and to plot
107/// faster.
108///
109/// Implementation is backed by a file on disk.
110#[derive(Debug, Clone)]
111pub struct DiskPieceCache {
112    inner: Arc<Inner>,
113}
114
115#[async_trait]
116impl farm::PieceCache for DiskPieceCache {
117    fn id(&self) -> &PieceCacheId {
118        &self.inner.id
119    }
120
121    #[inline]
122    fn max_num_elements(&self) -> u32 {
123        self.inner.max_num_elements
124    }
125
126    async fn contents(
127        &self,
128    ) -> Result<
129        Box<
130            dyn Stream<Item = Result<(PieceCacheOffset, Option<PieceIndex>), FarmError>>
131                + Unpin
132                + Send
133                + '_,
134        >,
135        FarmError,
136    > {
137        let this = self.clone();
138        let (mut sender, receiver) = mpsc::channel(100_000);
139        let span = Span::current();
140        let read_contents = task::spawn_blocking(move || {
141            let _guard = span.enter();
142
143            let contents = this.contents();
144            for (piece_cache_offset, maybe_piece) in contents {
145                if let Err(error) =
146                    Handle::current().block_on(sender.send(Ok((piece_cache_offset, maybe_piece))))
147                {
148                    debug!(%error, "Aborting contents iteration due to receiver dropping");
149                    break;
150                }
151            }
152        });
153        let read_contents = Mutex::new(Some(AsyncJoinOnDrop::new(read_contents, false)));
154        // Change order such that in closure below `receiver` is dropped before `read_contents`
155        let mut receiver = receiver;
156
157        Ok(Box::new(stream::poll_fn(move |ctx| {
158            let poll_result = receiver.poll_next_unpin(ctx);
159
160            if matches!(poll_result, Poll::Ready(None)) {
161                read_contents.lock().take();
162            }
163
164            poll_result
165        })))
166    }
167
168    async fn write_piece(
169        &self,
170        offset: PieceCacheOffset,
171        piece_index: PieceIndex,
172        piece: &Piece,
173    ) -> Result<(), FarmError> {
174        let piece = piece.clone();
175        let piece_cache = self.clone();
176        Ok(AsyncJoinOnDrop::new(
177            task::spawn_blocking(move || piece_cache.write_piece(offset, piece_index, &piece)),
178            false,
179        )
180        .await??)
181    }
182
183    async fn read_piece_index(
184        &self,
185        offset: PieceCacheOffset,
186    ) -> Result<Option<PieceIndex>, FarmError> {
187        let piece_cache = self.clone();
188        let span = Span::current();
189        Ok(AsyncJoinOnDrop::new(
190            task::spawn_blocking(move || {
191                let _guard = span.enter();
192
193                piece_cache.read_piece_index(offset)
194            }),
195            false,
196        )
197        .await??)
198    }
199
200    async fn read_piece(
201        &self,
202        offset: PieceCacheOffset,
203    ) -> Result<Option<(PieceIndex, Piece)>, FarmError> {
204        let span = Span::current();
205
206        // TODO: On Windows spawning blocking task that allows concurrent reads causes huge memory
207        //  usage. No idea why it happens, but not spawning anything at all helps for some reason.
208        //  Someone at some point should figure it out and fix, but it will probably be not me
209        //  (Nazar).
210        //  See https://github.com/autonomys/subspace/issues/2813 and linked forum post for details.
211        //  This TODO exists in multiple files
212        if cfg!(windows) {
213            Ok(task::block_in_place(|| {
214                let _guard = span.enter();
215
216                self.read_piece(offset)
217            })?)
218        } else {
219            let piece_cache = self.clone();
220            Ok(AsyncJoinOnDrop::new(
221                task::spawn_blocking(move || {
222                    let _guard = span.enter();
223
224                    piece_cache.read_piece(offset)
225                }),
226                false,
227            )
228            .await??)
229        }
230    }
231
232    async fn read_pieces(
233        &self,
234        offsets: Box<dyn Iterator<Item = PieceCacheOffset> + Send>,
235    ) -> Result<
236        Box<
237            dyn Stream<Item = Result<(PieceCacheOffset, Option<(PieceIndex, Piece)>), FarmError>>
238                + Send
239                + Unpin
240                + '_,
241        >,
242        FarmError,
243    > {
244        let iter = offsets.map(move |offset| async move {
245            Ok((offset, farm::PieceCache::read_piece(self, offset).await?))
246        });
247        Ok(Box::new(
248            // Constrain concurrency to avoid excessive memory usage, while still getting
249            // performance of concurrent reads
250            stream::iter(iter).buffer_unordered(PIECES_READING_CONCURRENCY),
251        ))
252    }
253}
254
255impl DiskPieceCache {
256    pub(crate) const FILE_NAME: &'static str = "piece_cache.bin";
257
258    /// Open cache, capacity is measured in elements of [`DiskPieceCache::element_size()`] size
259    pub fn open(
260        directory: &Path,
261        capacity: NonZeroU32,
262        id: Option<PieceCacheId>,
263        registry: Option<&mut Registry>,
264    ) -> Result<Self, DiskPieceCacheError> {
265        let capacity = capacity.get();
266        let files = FilePool::open(&directory.join(Self::FILE_NAME))?;
267
268        let expected_size = u64::from(Self::element_size()) * u64::from(capacity);
269        // Align plot file size for disk sector size
270        let expected_size =
271            expected_size.div_ceil(AlignedPage::SIZE as u64) * AlignedPage::SIZE as u64;
272        {
273            let file = files.write();
274            if file.size()? != expected_size {
275                // Allocating the whole file (`set_len` below can create a sparse file, which will
276                // cause writes to fail later)
277                file.preallocate(expected_size)
278                    .map_err(DiskPieceCacheError::CantPreallocateCacheFile)?;
279                // Truncating file (if necessary)
280                file.set_len(expected_size)?;
281            }
282        }
283
284        // ID for cache is ephemeral unless provided explicitly
285        let id = id.unwrap_or_else(PieceCacheId::new);
286        let metrics = registry.map(|registry| DiskPieceCacheMetrics::new(registry, &id, capacity));
287
288        Ok(Self {
289            inner: Arc::new(Inner {
290                id,
291                files,
292                max_num_elements: capacity,
293                metrics,
294            }),
295        })
296    }
297
298    /// Size of a single piece cache element
299    pub const fn element_size() -> u32 {
300        ELEMENT_SIZE as u32
301    }
302
303    /// Contents of this piece cache
304    ///
305    /// NOTE: it is possible to do concurrent reads and writes, higher level logic must ensure this
306    /// doesn't happen for the same piece being accessed!
307    pub(crate) fn contents(
308        &self,
309    ) -> impl ExactSizeIterator<Item = (PieceCacheOffset, Option<PieceIndex>)> + '_ {
310        let mut element = vec![0; ELEMENT_SIZE];
311        let count_total = self.inner.metrics.as_ref().is_some_and(|metrics| {
312            metrics.contents.inc();
313            metrics.capacity_used.get() == 0
314        });
315        let mut current_skip = 0;
316
317        // TODO: Parallelize or read in larger batches
318        (0..self.inner.max_num_elements).map(move |offset| {
319            if current_skip > CONTENTS_READ_SKIP_LIMIT {
320                return (PieceCacheOffset(offset), None);
321            }
322
323            match self.read_piece_internal(offset, &mut element) {
324                Ok(maybe_piece_index) => {
325                    if maybe_piece_index.is_none() {
326                        current_skip += 1;
327                    } else {
328                        if count_total && let Some(metrics) = &self.inner.metrics {
329                            metrics.capacity_used.inc();
330                        }
331                        current_skip = 0;
332                    }
333
334                    (PieceCacheOffset(offset), maybe_piece_index)
335                }
336                Err(error) => {
337                    warn!(%error, %offset, "Failed to read cache element");
338
339                    current_skip += 1;
340
341                    (PieceCacheOffset(offset), None)
342                }
343            }
344        })
345    }
346
347    /// Store piece in cache at specified offset, replacing existing piece if there is one.
348    ///
349    /// NOTE: it is possible to do concurrent reads and writes, higher level logic must ensure this
350    /// doesn't happen for the same piece being accessed!
351    pub(crate) fn write_piece(
352        &self,
353        offset: PieceCacheOffset,
354        piece_index: PieceIndex,
355        piece: &Piece,
356    ) -> Result<(), DiskPieceCacheError> {
357        let PieceCacheOffset(offset) = offset;
358        if offset >= self.inner.max_num_elements {
359            return Err(DiskPieceCacheError::OffsetOutsideOfRange {
360                provided: offset,
361                max: self.inner.max_num_elements - 1,
362            });
363        }
364
365        if let Some(metrics) = &self.inner.metrics {
366            metrics.write_piece.inc();
367            let capacity_used = i64::from(offset + 1);
368            if metrics.capacity_used.get() != capacity_used {
369                metrics.capacity_used.set(capacity_used);
370            }
371        }
372        let element_offset = u64::from(offset) * u64::from(Self::element_size());
373
374        let piece_index_bytes = piece_index.to_bytes();
375        // Combine all data with padding here, such that the whole element is written at once
376        let mut bytes = Vec::with_capacity(ELEMENT_SIZE);
377        bytes.extend_from_slice(&piece_index_bytes);
378        bytes.extend_from_slice(piece.as_ref());
379        bytes.extend_from_slice(
380            {
381                let mut hasher = blake3::Hasher::new();
382                hasher.update(&piece_index_bytes);
383                hasher.update(piece.as_ref());
384                hasher.finalize()
385            }
386            .as_bytes(),
387        );
388        bytes.resize(ELEMENT_SIZE, 0);
389        self.inner
390            .files
391            .write()
392            .write_all_at(&bytes, element_offset)?;
393
394        Ok(())
395    }
396
397    /// Read piece index from cache at specified offset.
398    ///
399    /// Returns `None` if offset is out of range.
400    ///
401    /// NOTE: it is possible to do concurrent reads and writes, higher level logic must ensure this
402    /// doesn't happen for the same piece being accessed!
403    pub(crate) fn read_piece_index(
404        &self,
405        offset: PieceCacheOffset,
406    ) -> Result<Option<PieceIndex>, DiskPieceCacheError> {
407        let PieceCacheOffset(offset) = offset;
408        if offset >= self.inner.max_num_elements {
409            warn!(%offset, "Trying to read piece out of range, this must be an implementation bug");
410            return Err(DiskPieceCacheError::OffsetOutsideOfRange {
411                provided: offset,
412                max: self.inner.max_num_elements - 1,
413            });
414        }
415
416        if let Some(metrics) = &self.inner.metrics {
417            metrics.read_piece_index.inc();
418        }
419        self.read_piece_internal(offset, &mut vec![0; ELEMENT_SIZE])
420    }
421
422    /// Read piece from cache at specified offset.
423    ///
424    /// Returns `None` if offset is out of range.
425    ///
426    /// NOTE: it is possible to do concurrent reads and writes, higher level logic must ensure this
427    /// doesn't happen for the same piece being accessed!
428    pub(crate) fn read_piece(
429        &self,
430        offset: PieceCacheOffset,
431    ) -> Result<Option<(PieceIndex, Piece)>, DiskPieceCacheError> {
432        let PieceCacheOffset(offset) = offset;
433        if offset >= self.inner.max_num_elements {
434            warn!(%offset, "Trying to read piece out of range, this must be an implementation bug");
435            return Err(DiskPieceCacheError::OffsetOutsideOfRange {
436                provided: offset,
437                max: self.inner.max_num_elements - 1,
438            });
439        }
440
441        if let Some(metrics) = &self.inner.metrics {
442            metrics.read_piece.inc();
443        }
444        let mut element = BytesMut::zeroed(ELEMENT_SIZE);
445        if let Some(piece_index) = self.read_piece_internal(offset, &mut element)? {
446            let element = element.freeze();
447            let piece =
448                Piece::try_from(element.slice_ref(&element[PieceIndex::SIZE..][..Piece::SIZE]))
449                    .expect("Correct length; qed");
450            Ok(Some((piece_index, piece)))
451        } else {
452            Ok(None)
453        }
454    }
455
456    fn read_piece_internal(
457        &self,
458        offset: u32,
459        element: &mut [u8],
460    ) -> Result<Option<PieceIndex>, DiskPieceCacheError> {
461        self.inner
462            .files
463            .read()
464            .read_exact_at(element, u64::from(offset) * u64::from(Self::element_size()))?;
465
466        let (piece_index_bytes, remaining_bytes) = element.split_at(PieceIndex::SIZE);
467        let (piece_bytes, remaining_bytes) = remaining_bytes.split_at(Piece::SIZE);
468        // The rest of the element after the checksum is zero padding
469        let (expected_checksum, _padding) = remaining_bytes.split_at(Blake3Hash::SIZE);
470
471        // Verify checksum
472        let actual_checksum = {
473            let mut hasher = blake3::Hasher::new();
474            hasher.update(piece_index_bytes);
475            hasher.update(piece_bytes);
476            *hasher.finalize().as_bytes()
477        };
478        if actual_checksum != expected_checksum {
479            if element.iter().all(|&byte| byte == 0) {
480                return Ok(None);
481            }
482
483            debug!(
484                actual_checksum = %hex::encode(actual_checksum),
485                expected_checksum = %hex::encode(expected_checksum),
486                "Hash doesn't match, corrupted piece in cache"
487            );
488
489            return Err(DiskPieceCacheError::ChecksumMismatch);
490        }
491
492        let piece_index = PieceIndex::from_bytes(
493            piece_index_bytes
494                .try_into()
495                .expect("Statically known to have correct size; qed"),
496        );
497        Ok(Some(piece_index))
498    }
499
500    pub(crate) fn wipe(directory: &Path) -> io::Result<()> {
501        let piece_cache = directory.join(Self::FILE_NAME);
502        if !piece_cache.exists() {
503            return Ok(());
504        }
505        info!("Deleting piece cache file at {}", piece_cache.display());
506        fs::remove_file(piece_cache)
507    }
508}