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}