Skip to main content

feldera_storage/
lib.rs

1//! Common Types and Trait Definition for Storage in Feldera.
2
3use std::collections::HashSet;
4use std::fmt::Debug;
5use std::io::{Cursor, ErrorKind};
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use std::sync::atomic::AtomicI64;
9
10use feldera_types::checkpoint::{CheckpointDependencies, CheckpointMetadata, PSpineBatches};
11use feldera_types::config::{StorageBackendConfig, StorageConfig, StorageOptions};
12use feldera_types::constants::{CHECKPOINT_DEPENDENCIES, CREATE_FILE_EXTENSION};
13use serde::de::DeserializeOwned;
14use tracing::warn;
15use uuid::Uuid;
16
17use crate::block::BlockLocation;
18use crate::error::StorageError;
19use crate::fbuf::FBuf;
20use crate::file::FileId;
21
22pub use object_store::path::{Path as StoragePath, PathPart as StoragePathPart};
23
24pub mod block;
25pub mod checkpoint_synchronizer;
26pub mod error;
27pub mod fbuf;
28pub mod file;
29pub mod histogram;
30pub mod metrics;
31pub mod tokio;
32
33/// Helper function that appends to a [`PathBuf`].
34pub fn append_to_path(p: PathBuf, s: &str) -> PathBuf {
35    let mut p = p.into_os_string();
36    p.push(s);
37    p.into()
38}
39
40pub trait StorageBackendFactory: Sync {
41    fn backend(&self) -> &'static str;
42    fn create(
43        &self,
44        storage_config: &StorageConfig,
45        backend_config: &StorageBackendConfig,
46    ) -> Result<Arc<dyn StorageBackend>, StorageError>;
47}
48
49inventory::collect!(&'static dyn StorageBackendFactory);
50
51/// A storage backend.
52pub trait StorageBackend: Send + Sync {
53    /// Create a new file with the given `name`, automatically creating any
54    /// parent directories within `name` that don't already exist.
55    fn create_named(&self, name: &StoragePath) -> Result<Box<dyn FileWriter>, StorageError>;
56
57    /// Creates a new persistent file used for writing data. The backend selects
58    /// a name.
59    fn create(&self) -> Result<Box<dyn FileWriter>, StorageError> {
60        self.create_with_prefix(&StoragePath::default())
61    }
62
63    /// Creates a new persistent file used for writing data, giving the file's
64    /// name the specified `prefix`. See also [`create`](Self::create).
65    fn create_with_prefix(
66        &self,
67        prefix: &StoragePath,
68    ) -> Result<Box<dyn FileWriter>, StorageError> {
69        let uuid = Uuid::now_v7();
70        let name = format!("{}{}{}", prefix, uuid, CREATE_FILE_EXTENSION);
71        self.create_named(&name.into())
72    }
73
74    /// Opens `name` for reading.
75    fn open(&self, name: &StoragePath) -> Result<Arc<dyn FileReader>, StorageError>;
76
77    /// Returns the base directory path on the local file system if the storage backend
78    /// uses local disk.
79    fn file_system_path(&self) -> Option<&Path> {
80        None
81    }
82
83    /// Calls `cb` with the name and type of each file under `parent`. This is a
84    /// non-recursive list: it does not include files under sub-directories of
85    /// `parent`.
86    ///
87    /// This method can report two classes of errors:
88    ///
89    /// - The return value indicates errors that could prevent `cb` from being
90    ///   called for some or all of the files in the directory.  These errors
91    ///   indicate that `parent` does not exist or cannot be (fully) read
92    ///   successfully.  If more than one such error occurs, the method returns
93    ///   the last one.
94    ///
95    /// - [DirEntry::file_type] in the argument to the callback reports errors
96    ///   obtaining the file type.  Errors reported this way only mean that
97    ///   there was a problem obtaining metadata for the file itself.
98    fn list(&self, parent: &StoragePath, cb: &mut dyn FnMut(DirEntry)) -> Result<(), StorageError>;
99
100    fn delete(&self, name: &StoragePath) -> Result<(), StorageError>;
101
102    fn delete_recursive(&self, name: &StoragePath) -> Result<(), StorageError>;
103
104    fn delete_if_exists(&self, name: &StoragePath) -> Result<(), StorageError> {
105        match self.delete(name) {
106            Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
107            other => other,
108        }
109    }
110
111    fn exists(&self, name: &StoragePath) -> Result<bool, StorageError> {
112        match self.open(name) {
113            Ok(_) => Ok(true),
114            Err(error) if error.kind() == ErrorKind::NotFound => Ok(false),
115            Err(error) => Err(error),
116        }
117    }
118
119    /// Reads `name` and returns its contents.  The file `name` is relative to
120    /// the base of the storage backend.
121    fn read(&self, name: &StoragePath) -> Result<Arc<FBuf>, StorageError> {
122        let reader = self.open(name)?;
123        let size = reader.get_size()?.try_into().unwrap();
124        reader.read_block(BlockLocation { offset: 0, size })
125    }
126
127    /// Writes `content` to `name`, automatically creating any parent
128    /// directories within `name` that don't already exist.
129    ///
130    /// The caller must call `commit` on the returned file if it wants to make
131    /// sure that the file is committed to stable storage.
132    fn write(
133        &self,
134        name: &StoragePath,
135        content: FBuf,
136    ) -> Result<Arc<dyn FileCommitter>, StorageError> {
137        let mut writer = self.create_named(name)?;
138        writer.write_block(content)?;
139        let reader = writer.complete()?;
140        reader.mark_for_checkpoint();
141        Ok(reader)
142    }
143
144    /// Flushes the directory entry metadata for `dir` to stable storage.
145    ///
146    /// POSIX `fsync(file)` guarantees the file's data is durable but says
147    /// nothing about whether the file's name in its parent directory is
148    /// durable. After renaming files into a directory or creating new
149    /// subdirectories, the caller can use this barrier to make those
150    /// directory entries durable in one syscall.
151    ///
152    /// Backends that do not have a meaningful notion of directory
153    /// durability (e.g. object stores) may treat this as a no-op.
154    fn fsync_dir(&self, _dir: &StoragePath) -> Result<(), StorageError> {
155        Ok(())
156    }
157
158    /// Returns a value that represents the number of bytes of storage in use.
159    /// The storage backend updates this value when its own functions cause more
160    /// or less storage to be used:
161    ///
162    /// - Writing to a file.
163    ///
164    /// - Deleting a file (by dropping a [FileWriter] without completing, or by
165    ///   dropping a [FileReader] without marking it for a checkpoint, or by
166    ///   calling functions to delete files.
167    ///
168    /// The backend is *not* required to:
169    ///
170    /// - Initially report how much storage is in use. Instead, it just starts
171    ///   out at zero. The client can traverse the storage itself and store the
172    ///   correct initial value.
173    ///
174    /// - Detect changes made by a different backend or outside any backend.
175    ///
176    /// The value is signed because the problems above can cause it to become
177    /// negative.
178    fn usage(&self) -> Arc<AtomicI64>;
179}
180
181impl dyn StorageBackend {
182    /// Creates and returns a new backend configured according to `config` and `options`.
183    pub fn new(
184        config: &StorageConfig,
185        options: &StorageOptions,
186    ) -> Result<Arc<Self>, StorageError> {
187        Self::warn_about_tmpfs(config.path());
188        for variable_provider in inventory::iter::<&dyn StorageBackendFactory> {
189            if variable_provider.backend() == options.backend.to_string() {
190                return variable_provider.create(config, &options.backend);
191            }
192        }
193        Err(StorageError::BackendNotSupported(Box::new(
194            options.backend.clone(),
195        )))
196    }
197
198    fn is_tmpfs(_path: &Path) -> bool {
199        #[cfg(target_os = "linux")]
200        {
201            use nix::sys::statfs;
202            statfs::statfs(_path).is_ok_and(|s| s.filesystem_type() == statfs::TMPFS_MAGIC)
203        }
204
205        #[cfg(not(target_os = "linux"))]
206        false
207    }
208
209    fn warn_about_tmpfs(path: &Path) {
210        if Self::is_tmpfs(path) {
211            static ONCE: std::sync::Once = std::sync::Once::new();
212            ONCE.call_once(|| {
213                warn!("initializing storage on in-memory tmpfs filesystem at {}; consider configuring physical storage", path.display())
214            });
215        }
216    }
217
218    pub fn gather_batches_for_checkpoint_uuid(
219        &self,
220        cpm: uuid::Uuid,
221    ) -> Result<HashSet<StoragePath>, StorageError> {
222        assert!(!cpm.is_nil());
223
224        let checkpoint_dir: StoragePath = cpm.to_string().into();
225
226        // `dependencies.json` holds the authoritative snapshot of every
227        // batch referenced by the checkpoint at commit time. Prefer it
228        // over the per-spine `pspine-batches-*.dat` scan below: a valid
229        // commit always writes it, and it captures the full list in one
230        // place so a single read suffices. See `CheckpointDependencies`
231        // for the accepted JSON forms.
232        let deps_path = checkpoint_dir.child(CHECKPOINT_DEPENDENCIES);
233        match self.read_json::<CheckpointDependencies>(&deps_path) {
234            Ok(deps) => {
235                return Ok(deps
236                    .batches()
237                    .iter()
238                    .map(|b| StoragePath::from(b.as_str()))
239                    .collect());
240            }
241            Err(error) if error.kind() == ErrorKind::NotFound => {
242                // Fall back to scanning per-spine sidecars. Predates the
243                // `dependencies.json` snapshot entirely.
244            }
245            Err(error) => return Err(error),
246        }
247
248        // Legacy fallback. New checkpoints always carry a
249        // `dependencies.json` (the early-return above), so this scan only
250        // runs for checkpoints written before that file existed.
251        // TODO: remove once no such old checkpoints remain in use.
252        let mut spines = Vec::new();
253        self.list(&checkpoint_dir, &mut |entry| {
254            if let Some(filename) = entry.name.filename()
255                && filename.starts_with("pspine-batches")
256            {
257                spines.push(entry.name);
258            }
259        })?;
260
261        let mut batch_files_in_commit: HashSet<StoragePath> = HashSet::new();
262        for spine in spines {
263            let pspine_batches = self.read_json::<PSpineBatches>(&spine)?;
264            for file in pspine_batches.files {
265                batch_files_in_commit.insert(file.into());
266            }
267        }
268
269        Ok(batch_files_in_commit)
270    }
271
272    pub fn gather_batches_for_checkpoint(
273        &self,
274        cpm: &CheckpointMetadata,
275    ) -> Result<HashSet<StoragePath>, StorageError> {
276        self.gather_batches_for_checkpoint_uuid(cpm.uuid)
277    }
278}
279
280// For an explanation of the `+ '_` here, see:
281// https://stackoverflow.com/questions/73495603/trait-problem-borrowed-data-escapes-outside-of-associated-function
282impl dyn StorageBackend + '_ {
283    /// Writes `content` to `name` as JSON, automatically creating any parent
284    /// directories within `name` that don't already exist.
285    ///
286    /// The caller must call `commit` on the returned file if it wants to make
287    /// sure that the file is committed to stable storage.
288    pub fn write_json<V>(
289        &self,
290        name: &StoragePath,
291        value: &V,
292    ) -> Result<Arc<dyn FileCommitter>, StorageError>
293    where
294        V: serde::Serialize,
295    {
296        let mut content = FBuf::new();
297        serde_json::to_writer(&mut content, value).unwrap();
298        self.write(name, content)
299    }
300
301    /// Reads `name` as JSON.
302    pub fn read_json<V>(&self, name: &StoragePath) -> Result<V, StorageError>
303    where
304        V: DeserializeOwned,
305    {
306        let content = self.read(name)?;
307        serde_json::from_reader(Cursor::new(content.as_ref()))
308            .map_err(|e| StorageError::JsonError(e.to_string()))
309    }
310}
311
312/// A directory entry read by [StorageBackend::list].
313pub struct DirEntry {
314    /// File name.
315    pub name: StoragePath,
316
317    /// File type, if it could be obtained.
318    pub file_type: Result<StorageFileType, StorageError>,
319}
320
321/// A file being read or written.
322pub trait FileRw {
323    /// Returns the file's unique ID.
324    fn file_id(&self) -> FileId;
325
326    /// Returns the file's path.
327    fn path(&self) -> &StoragePath;
328}
329
330/// A file being written.
331///
332/// The file can't be read until it is completed with
333/// [FileWriter::complete]. Until then, the file is temporary and will be
334/// deleted if it is dropped.
335pub trait FileWriter: Send + Sync + FileRw {
336    /// Writes `data` at the end of the file. len()` must be a multiple of 512.
337    /// Returns the data that was written encapsulated in an `Arc`.
338    fn write_block(&mut self, data: FBuf) -> Result<Arc<FBuf>, StorageError>;
339
340    /// Completes writing of a file and returns a reader for the file.
341    ///
342    /// The file will be deleted if the reader is dropped without calling
343    /// [FileReader::mark_for_checkpoint].
344    ///
345    /// The file is not necessarily committed to stable storage before calling
346    /// `commit` on the returned file.
347    fn complete(self: Box<Self>) -> Result<Arc<dyn FileReader>, StorageError>;
348}
349
350/// Allows a file to be committed to stable storage.
351///
352/// This is a supertrait of [FileReader] that only allows the commit operation.
353/// It's somewhat surprising that a file that can't be written can be committed,
354/// but it makes sense in the context of [FileWriter::complete] returning a
355/// [FileReader] that isn't necessarily committed yet.  Making this a separate
356/// trait allows code to split off a `FileCommitter` to hand to a piece of code
357/// that only needs to be able to commit it.
358pub trait FileCommitter: Send + Sync + Debug + FileRw {
359    /// Commits the file to stable storage.
360    fn commit(&self) -> Result<(), StorageError>;
361}
362
363/// A readable file.
364pub trait FileReader: Send + Sync + Debug + FileRw + FileCommitter {
365    /// Marks a file to be part of a checkpoint.
366    ///
367    /// This is used to prevent the file from being deleted when it is dropped.
368    /// This is only useful for files obtained via [FileWriter::complete],
369    /// because files that were opened with [StorageBackend::open] are never
370    /// deleted on drop.
371    fn mark_for_checkpoint(&self);
372
373    /// Reads data at `location` from the file.  If successful, the result will
374    /// be exactly the requested length; that is, this API treats read past EOF
375    /// as an error.
376    fn read_block(&self, location: BlockLocation) -> Result<Arc<FBuf>, StorageError>;
377
378    /// Initiates an asynchronous read.  When the read completes, `callback`
379    /// will be called.
380    ///
381    /// The default implementation is not actually asynchronous.
382    #[allow(clippy::type_complexity)]
383    fn read_async(
384        &self,
385        blocks: Vec<BlockLocation>,
386        callback: Box<dyn FnOnce(Vec<Result<Arc<FBuf>, StorageError>>) + Send>,
387    ) {
388        default_read_async(self, blocks, callback);
389    }
390
391    /// Returns the file's size in bytes.
392    fn get_size(&self) -> Result<u64, StorageError>;
393}
394
395/// Default implementation for [FileReader::read_async].
396///
397/// This implementation is not actually asynchronous.
398#[allow(clippy::type_complexity)]
399pub fn default_read_async<R>(
400    reader: &R,
401    blocks: Vec<BlockLocation>,
402    callback: Box<dyn FnOnce(Vec<Result<Arc<FBuf>, StorageError>>) + Send>,
403) where
404    R: FileReader + ?Sized,
405{
406    callback(
407        blocks
408            .into_iter()
409            .map(|location| reader.read_block(location))
410            .collect(),
411    )
412}
413
414#[derive(Copy, Clone, Debug, PartialEq, Eq)]
415pub enum StorageFileType {
416    /// A regular file.
417    File {
418        /// File size in bytes.
419        size: u64,
420    },
421
422    /// A directory.
423    ///
424    /// Only some kinds of storage backends support directories. The ones that
425    /// don't still allow files to be named hierarchically, but they don't
426    /// support creating or deleting directories independently from the files in
427    /// them. That is, with such a backend, a directory is effectively created
428    /// by creating a file in it, and is effectively deleted when the last file
429    /// in it is deleted.
430    Directory,
431
432    /// Something else.
433    Other,
434}