Skip to main content

snix_castore/directoryservice/
redb.rs

1use futures::{StreamExt, TryStreamExt, stream::BoxStream};
2use prost::Message;
3use redb::{ReadableDatabase, TableDefinition};
4use std::{path::PathBuf, sync::Arc};
5use tonic::async_trait;
6use tracing::{instrument, warn};
7
8use super::{Directory, DirectoryPutter, DirectoryService, traversal};
9use crate::{
10    B3Digest,
11    composition::{CompositionContext, ServiceBuilder},
12    directoryservice::{
13        directory_graph::DirectoryGraphBuilder,
14        order_validator::{self, LeavesToRoot},
15    },
16    proto,
17};
18
19const DIRECTORY_TABLE: TableDefinition<[u8; B3Digest::LENGTH], Vec<u8>> =
20    TableDefinition::new("directory");
21
22enum Db {
23    ReadOnly(redb::ReadOnlyDatabase),
24    ReadWrite(redb::Database),
25}
26
27impl Db {
28    fn begin_read(&self) -> Result<redb::ReadTransaction, Error> {
29        match self {
30            Db::ReadOnly(db) => Ok(db.begin_read()?),
31            Db::ReadWrite(db) => Ok(db.begin_read()?),
32        }
33    }
34
35    fn begin_write(&self) -> Result<redb::WriteTransaction, Error> {
36        match self {
37            Db::ReadOnly(_) => Err(Error::OpenedReadonly),
38            Db::ReadWrite(db) => Ok(db.begin_write()?),
39        }
40    }
41}
42
43#[derive(Clone)]
44pub struct RedbDirectoryService {
45    instance_name: String,
46
47    /// An Arc'ed Database, read-only or writeable.
48    db: Arc<Db>,
49}
50
51impl RedbDirectoryService {
52    /// Constructs a new instance using the specified config.
53    pub async fn new(
54        instance_name: String,
55        config: RedbDirectoryServiceConfig,
56    ) -> Result<Self, Error> {
57        if let Some(path) = config.path.as_ref() {
58            if path == "" {
59                return Err(Error::WrongConfig("empty path is disallowed"));
60            }
61            if path == "/" {
62                return Err(Error::WrongConfig("cowardly refusing to open / with redb"));
63            }
64
65            if !config.read_only
66                && let Some(parent) = path.parent()
67            {
68                tokio::fs::create_dir_all(parent).await?;
69            }
70        }
71
72        let db = if config.path.is_some() {
73            tokio::task::spawn_blocking(move || {
74                let mut builder = redb::Database::builder();
75                configure_builder(&mut builder, &config);
76
77                let path = config.path.expect("Snix bug: path is Some");
78
79                if config.read_only {
80                    Ok::<_, Error>(Db::ReadOnly(builder.open_read_only(&path)?))
81                } else {
82                    let db = builder.create(&path)?;
83                    create_schema(&db)?;
84                    Ok(Db::ReadWrite(db))
85                }
86            })
87            .await??
88        } else {
89            if config.read_only {
90                return Err(Error::WrongConfig("in-memory database cannot be read-only"));
91            }
92            let mut builder = redb::Database::builder();
93            configure_builder(&mut builder, &config);
94
95            let db = builder
96                .create_with_backend(redb::backends::InMemoryBackend::new())
97                .expect("Snix bug: unable to create in-memory redb");
98
99            create_schema(&db)?;
100            Db::ReadWrite(db)
101        };
102
103        Ok(Self {
104            instance_name,
105            db: Arc::new(db),
106        })
107    }
108
109    /// Constructs a new instance using the in-memory backend.
110    /// Only used for testing purposes and mocks, use [Self::new] with a None
111    /// path in config for other usecases.
112    /// Sync, as there's no real IO happening.
113    #[cfg(any(test, feature = "mocks"))]
114    pub fn new_temporary(instance_name: String) -> Self {
115        let mut builder = redb::Database::builder();
116        configure_builder(
117            &mut builder,
118            &RedbDirectoryServiceConfig {
119                path: None,
120                cache_size: None,
121                read_only: false,
122            },
123        );
124
125        let db = builder
126            .create_with_backend(redb::backends::InMemoryBackend::new())
127            .expect("Snix bug: unable to create in-memory redb");
128
129        create_schema(&db).expect("Snix bug: unable to create schema for in-memory redb");
130
131        Self {
132            instance_name,
133            db: Arc::new(Db::ReadWrite(db)),
134        }
135    }
136}
137
138/// Applies options from [RedbDirectoryServiceConfig] to a [redb::Builder].
139fn configure_builder(builder: &mut redb::Builder, config: &RedbDirectoryServiceConfig) {
140    if let Some(cache_size) = config.cache_size {
141        builder.set_cache_size(cache_size);
142    }
143}
144
145/// Ensures all tables are present.
146/// Opens a write transaction and calls open_table on DIRECTORY_TABLE, which will
147/// create it if not present.
148#[allow(clippy::result_large_err)]
149fn create_schema(db: &redb::Database) -> Result<(), Error> {
150    let txn = db.begin_write()?;
151    txn.open_table(DIRECTORY_TABLE)?;
152    txn.commit()?;
153
154    Ok(())
155}
156
157#[async_trait]
158impl DirectoryService for RedbDirectoryService {
159    #[instrument(skip(self, digest), fields(directory.digest = %digest, instance_name = %self.instance_name))]
160    async fn get(&self, digest: &B3Digest) -> Result<Option<Directory>, super::Error> {
161        let db = self.db.clone();
162        let digest = *digest;
163        // Retrieves the protobuf-encoded Directory for the corresponding digest.
164        let directory_data = match tokio::task::spawn_blocking(move || -> Result<_, Error> {
165            let txn = db.begin_read()?;
166            let table = txn.open_table(DIRECTORY_TABLE)?;
167            Ok(table.get(*digest)?)
168        })
169        .await
170        .map_err(Error::TokioJoin)??
171        {
172            // The Directory was not found, return None.
173            None => return Ok(None),
174            Some(directory_data) => directory_data.value(),
175        };
176
177        // We check that the digest of the retrieved Directory matches the expected digest.
178        let actual = B3Digest::from(blake3::hash(&directory_data));
179        if actual != digest {
180            return Err(Error::WrongDigest {
181                expected: digest,
182                actual,
183            }
184            .into());
185        }
186
187        // Attempt to decode the retrieved protobuf-encoded Directory
188        let proto_directory =
189            proto::Directory::decode(directory_data.as_slice()).map_err(Error::ProtobufDecode)?;
190        let directory = Directory::try_from(proto_directory).map_err(Error::DirectoryValidation)?;
191
192        Ok(Some(directory))
193    }
194
195    #[instrument(skip(self, directory), fields(directory.digest = %directory.digest(), instance_name = %self.instance_name))]
196    async fn put(&self, directory: Directory) -> Result<B3Digest, super::Error> {
197        let db = self.db.clone();
198        let digest = tokio::task::spawn_blocking(move || -> Result<_, Error> {
199            let digest = directory.digest();
200
201            // Store the directory in the table.
202            let txn = db.begin_write()?;
203            {
204                let mut table = txn.open_table(DIRECTORY_TABLE)?;
205                table.insert(
206                    digest.as_ref(),
207                    proto::Directory::from(directory).encode_to_vec(),
208                )?;
209            }
210            txn.commit()?;
211
212            Ok(digest)
213        })
214        .await
215        .map_err(Error::TokioJoin)??;
216
217        Ok(digest)
218    }
219
220    #[instrument(skip_all, fields(directory.digest = %root_directory_digest, instance_name = %self.instance_name))]
221    fn get_recursive(
222        &self,
223        root_directory_digest: &B3Digest,
224    ) -> BoxStream<'static, Result<Directory, super::Error>> {
225        // FUTUREWORK: Ideally we should have all of the directory traversing happen in a single
226        // redb transaction to avoid constantly closing and opening new transactions for the
227        // database.
228        let svc = self.clone();
229        traversal::root_to_leaves(*root_directory_digest, move |digest| {
230            let svc = svc.clone();
231            async move { svc.get(&digest).await }
232        })
233        .map_err(Error::DirectoryTraversal)
234        .err_into()
235        .boxed()
236    }
237
238    #[instrument(skip_all)]
239    fn put_multiple_start(&self) -> Box<dyn DirectoryPutter> {
240        Box::new(RedbDirectoryPutter {
241            db: self.db.clone(),
242            builder: Some(DirectoryGraphBuilder::<LeavesToRoot>::new()),
243        })
244    }
245}
246
247pub struct RedbDirectoryPutter {
248    db: Arc<Db>,
249
250    /// The directories (inside the directory validator) that we insert later,
251    /// or None, if they were already inserted.
252    builder: Option<DirectoryGraphBuilder<LeavesToRoot>>,
253}
254
255#[async_trait]
256impl DirectoryPutter for RedbDirectoryPutter {
257    #[instrument(level = "trace", skip_all, fields(directory.digest=%directory.digest()), err)]
258    async fn put(&mut self, directory: Directory) -> Result<(), super::Error> {
259        let builder = self
260            .builder
261            .as_mut()
262            .ok_or_else(|| Error::DirectoryPutterAlreadyClosed)?;
263
264        builder
265            .try_insert(directory)
266            .map_err(Error::DirectoryOrdering)?;
267
268        Ok(())
269    }
270
271    #[instrument(level = "trace", skip_all, ret, err)]
272    async fn close(&mut self) -> Result<B3Digest, super::Error> {
273        let builder = self
274            .builder
275            .take()
276            .ok_or_else(|| Error::DirectoryPutterAlreadyClosed)?;
277
278        // Insert all directories as a batch.
279        let db = self.db.clone();
280        let root_digest = tokio::task::spawn_blocking(move || {
281            // Retrieve the validated directories.
282            let directory_graph = builder.build().map_err(Error::DirectoryOrdering)?;
283            let root_digest = directory_graph.root().digest();
284
285            let txn = db.begin_write()?;
286            // Looping over all the verified directories, queuing them up for a
287            // batch insertion.
288            {
289                let mut table = txn.open_table(DIRECTORY_TABLE)?;
290                for directory in directory_graph.drain_leaves_to_root() {
291                    table.insert(
292                        directory.digest().as_ref(),
293                        proto::Directory::from(directory).encode_to_vec(),
294                    )?;
295                }
296            }
297            txn.commit()?;
298
299            Ok::<_, Error>(root_digest)
300        })
301        .await
302        .map_err(Error::TokioJoin)??;
303
304        Ok(root_digest)
305    }
306}
307
308#[derive(thiserror::Error, Debug)]
309pub enum Error {
310    #[error("wrong arguments: {0}")]
311    WrongConfig(&'static str),
312    #[error("serde-qs error: {0}")]
313    SerdeQS(#[from] serde_qs::Error),
314
315    #[error("Directory Graph ordering error")]
316    DirectoryOrdering(#[from] order_validator::OrderingError),
317
318    #[error("DirectoryPutter already closed")]
319    DirectoryPutterAlreadyClosed,
320
321    #[error("failure during directory traversal")]
322    DirectoryTraversal(#[source] traversal::Error),
323
324    #[error("requested directory has wrong digest, expected {expected}, actual {actual}")]
325    WrongDigest {
326        expected: B3Digest,
327        actual: B3Digest,
328    },
329    #[error("failed to decode protobuf: {0}")]
330    ProtobufDecode(#[from] prost::DecodeError),
331    #[error("failed to validate directory: {0}")]
332    DirectoryValidation(#[from] crate::DirectoryError),
333
334    #[error("unable to open write txn, database opened read-only")]
335    OpenedReadonly,
336    #[error("redb commit error: {0}")]
337    RedbCommit(#[from] redb::CommitError),
338    #[error("redb database error: {0}")]
339    RedbDatabase(#[from] redb::DatabaseError),
340    #[error("redb error: {0}")]
341    Redb(#[from] redb::Error),
342    #[error("redb storage error: {0}")]
343    RedbStorage(#[from] redb::StorageError),
344    #[error("redb table error: {0}")]
345    RedbTable(#[from] redb::TableError),
346    #[error("redb txn error: {0}")]
347    RedbTransaction(#[from] redb::TransactionError),
348
349    #[error("join error: {0}")]
350    TokioJoin(#[from] tokio::task::JoinError),
351    #[error("io error: {0}")]
352    IO(#[from] std::io::Error),
353}
354
355impl From<Error> for super::Error {
356    fn from(value: Error) -> Self {
357        Self(Box::new(value))
358    }
359}
360
361#[derive(Clone, Default, serde::Deserialize)]
362#[serde(deny_unknown_fields)]
363pub struct RedbDirectoryServiceConfig {
364    path: Option<PathBuf>,
365
366    /// The amount of memory (in bytes) used for caching data
367    cache_size: Option<usize>,
368
369    /// Whether to open read-only.
370    #[serde(default)]
371    read_only: bool,
372}
373
374impl TryFrom<url::Url> for RedbDirectoryServiceConfig {
375    type Error = Box<dyn std::error::Error + Send + Sync>;
376
377    fn try_from(url: url::Url) -> Result<Self, Self::Error> {
378        if url.has_host() {
379            return Err(Error::WrongConfig("no host allowed").into());
380        }
381
382        let path: Option<PathBuf> = match (url.scheme(), url.has_authority(), url.path()) {
383            ("redb+memory", false, "") => None,
384            ("redb+memory", false, _) => Err(Box::new(Error::WrongConfig(
385                "redb+memory with path is disallowed",
386            )))?,
387            ("redb+memory", true, _) => Err(Box::new(Error::WrongConfig(
388                "redb+memory may not have authority",
389            )))?,
390            ("redb", _, "") => Err(Box::new(Error::WrongConfig(
391                "redb without path is disallowed, use redb+memory if you want in-memory",
392            )))?,
393            ("redb", true, _path) => Err(Box::new(Error::WrongConfig("authority disallowed")))?,
394            ("redb", false, path) => Some(path.into()),
395            (_scheme, _, _) => Err(Box::new(Error::WrongConfig("unrecognized scheme")))?,
396        };
397
398        let mut config: RedbDirectoryServiceConfig =
399            serde_qs::from_str(url.query().unwrap_or_default())?;
400
401        config.path = path;
402
403        Ok(config)
404    }
405}
406
407#[async_trait]
408impl ServiceBuilder for RedbDirectoryServiceConfig {
409    type Output = dyn DirectoryService;
410    async fn build<'a>(
411        &'a self,
412        instance_name: &str,
413        _context: &CompositionContext,
414    ) -> Result<Arc<Self::Output>, Box<dyn std::error::Error + Send + Sync>> {
415        Ok(Arc::new(
416            RedbDirectoryService::new(instance_name.to_string(), self.to_owned()).await?,
417        ))
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use tempfile::TempDir;
424
425    use crate::{
426        directoryservice::{DirectoryService, RedbDirectoryService, RedbDirectoryServiceConfig},
427        fixtures::DIRECTORY_A,
428    };
429
430    #[tokio::test]
431    async fn reopen_as_read_only() {
432        let tempdir = TempDir::new().unwrap();
433        let path = tempdir.path().join("data.redb");
434
435        let config = RedbDirectoryServiceConfig {
436            path: Some(path),
437            cache_size: None,
438            read_only: false,
439        };
440
441        // Create a read-write directory service and insert some data.
442        {
443            let directory_service = RedbDirectoryService::new("rw".to_string(), config.clone())
444                .await
445                .expect("to construct");
446
447            directory_service
448                .put(DIRECTORY_A.clone())
449                .await
450                .expect("to insert");
451        } // we drop the rw database here.
452
453        // Re-open the same path in ro mode (twice)
454        let ro_config = RedbDirectoryServiceConfig {
455            read_only: true,
456            ..config
457        };
458
459        let directory_service_ro_1 =
460            RedbDirectoryService::new("ro1".to_string(), ro_config.clone())
461                .await
462                .expect("to construct");
463        let directory_service_ro_2 = RedbDirectoryService::new("ro2".to_string(), ro_config)
464            .await
465            .expect("to construct");
466
467        assert_eq!(
468            directory_service_ro_1
469                .get(&DIRECTORY_A.digest())
470                .await
471                .expect("get to succeed")
472                .expect("to be Some(_)")
473                .digest(),
474            DIRECTORY_A.digest()
475        );
476        assert_eq!(
477            directory_service_ro_2
478                .get(&DIRECTORY_A.digest())
479                .await
480                .expect("get to succeed")
481                .expect("to be Some(_)")
482                .digest(),
483            DIRECTORY_A.digest()
484        );
485    }
486
487    #[tokio::test]
488    async fn read_only_nonexistent() {
489        let tempdir = TempDir::new().unwrap();
490        let path = tempdir.path().join("data.redb");
491
492        let config = RedbDirectoryServiceConfig {
493            path: Some(path),
494            cache_size: None,
495            read_only: true,
496        };
497
498        // Opening a read-only redb should fail if the path doesn't exist.
499        assert!(
500            RedbDirectoryService::new("test".to_string(), config)
501                .await
502                .is_err(),
503            "opening new path r/o should fail"
504        );
505    }
506
507    #[tokio::test]
508    async fn open_rw_and_ro() {
509        let tempdir = TempDir::new().unwrap();
510        let path = tempdir.path().join("data.redb");
511
512        let config = RedbDirectoryServiceConfig {
513            path: Some(path),
514            cache_size: None,
515            read_only: false,
516        };
517
518        let _directory_service = RedbDirectoryService::new("rw".to_string(), config.clone())
519            .await
520            .expect("to construct");
521
522        // Opening a read-only redb should fail if it's already opened read-write.
523        assert!(
524            RedbDirectoryService::new(
525                "ro".to_string(),
526                RedbDirectoryServiceConfig {
527                    read_only: true,
528                    ..config
529                }
530            )
531            .await
532            .is_err(),
533            "opening r/o should fail if still open r/w"
534        );
535    }
536}