Skip to main content

snix_store/pathinfoservice/
redb.rs

1use super::{PathInfo, PathInfoService};
2use crate::{pathinfoservice, proto};
3use data_encoding::BASE64;
4use futures::{StreamExt, TryStreamExt, stream::BoxStream};
5use prost::Message;
6use redb::{ReadableDatabase, ReadableTable, ReadableTableMetadata, TableDefinition};
7use snix_castore::composition::{CompositionContext, ServiceBuilder};
8use std::{path::PathBuf, sync::Arc};
9use tokio_stream::wrappers::ReceiverStream;
10use tonic::async_trait;
11use tracing::instrument;
12
13const PATHINFO_TABLE: TableDefinition<[u8; 20], Vec<u8>> = TableDefinition::new("pathinfo");
14
15enum Db {
16    ReadOnly(redb::ReadOnlyDatabase),
17    ReadWrite(redb::Database),
18}
19
20impl Db {
21    fn begin_read(&self) -> Result<redb::ReadTransaction, redb::TransactionError> {
22        match self {
23            Db::ReadOnly(db) => db.begin_read(),
24            Db::ReadWrite(db) => db.begin_read(),
25        }
26    }
27
28    fn begin_write(&self) -> Result<redb::WriteTransaction, Error> {
29        match self {
30            Db::ReadOnly(_) => Err(Error::OpenedReadonly),
31            Db::ReadWrite(db) => Ok(db.begin_write()?),
32        }
33    }
34}
35
36/// PathInfoService implementation using redb under the hood.
37/// redb stores all of its data in a single file with a K/V pointing from a path's output hash to
38/// its corresponding protobuf-encoded PathInfo.
39#[derive(Clone)]
40pub struct RedbPathInfoService {
41    instance_name: String,
42
43    /// An Arc'ed Database, read-only or writeable.
44    db: Arc<Db>,
45}
46
47impl RedbPathInfoService {
48    /// Constructs a new instance using the specified config.
49    pub async fn new(
50        instance_name: String,
51        config: RedbPathInfoServiceConfig,
52    ) -> Result<Self, Error> {
53        if let Some(path) = config.path.clone() {
54            if &path == "" {
55                return Err(Error::WrongConfig("empty path is disallowed"));
56            }
57            if &path == "/" {
58                return Err(Error::WrongConfig("cowardly refusing to open / with redb"));
59            }
60
61            if config.read_only {
62                let db = tokio::task::spawn_blocking(move || {
63                    let mut builder = redb::Database::builder();
64                    configure_builder(&mut builder, &config);
65                    builder.open_read_only(path)
66                })
67                .await??;
68
69                return Ok(Self {
70                    instance_name,
71                    db: Arc::new(Db::ReadOnly(db)),
72                });
73            }
74
75            if let Some(parent) = path.parent() {
76                tokio::fs::create_dir_all(parent).await?;
77            }
78
79            let db = tokio::task::spawn_blocking(move || {
80                let mut builder = redb::Database::builder();
81                configure_builder(&mut builder, &config);
82
83                let db = builder.create(path)?;
84                create_schema(&db)?;
85                Ok::<_, Error>(db)
86            })
87            .await??;
88
89            Ok(Self {
90                instance_name,
91                db: Arc::new(Db::ReadWrite(db)),
92            })
93        } else {
94            Self::new_temporary(instance_name, config)
95        }
96    }
97
98    /// Constructs a new instance using the in-memory backend.
99    /// Sync, as there's no real IO happening.
100    pub fn new_temporary(
101        instance_name: String,
102        config: RedbPathInfoServiceConfig,
103    ) -> Result<Self, Error> {
104        debug_assert!(
105            config.path.is_none(),
106            "Snix bug: config.path is not None, but new_temporary requested"
107        );
108
109        if config.read_only {
110            return Err(Error::WrongConfig("in-memory database cannot be read-only"));
111        }
112
113        let mut builder = redb::Database::builder();
114        configure_builder(&mut builder, &config);
115
116        let db = builder.create_with_backend(redb::backends::InMemoryBackend::new())?;
117
118        create_schema(&db)?;
119
120        Ok(RedbPathInfoService {
121            instance_name,
122            db: Arc::new(Db::ReadWrite(db)),
123        })
124    }
125
126    /// Returns the number of PathInfo stored.
127    pub async fn count(&self) -> Result<u64, Error> {
128        let db = self.db.clone();
129
130        let count = tokio::task::spawn_blocking({
131            move || -> Result<_, Error> {
132                let txn = db.begin_read()?;
133                let table = txn.open_table(PATHINFO_TABLE)?;
134                Ok(table.len()?)
135            }
136        })
137        .await??;
138
139        Ok(count)
140    }
141}
142
143/// Applies options from [RedbPathInfoServiceConfig] to a [redb::Builder].
144fn configure_builder(builder: &mut redb::Builder, config: &RedbPathInfoServiceConfig) {
145    if let Some(cache_size) = config.cache_size {
146        builder.set_cache_size(cache_size);
147    }
148}
149
150/// Ensures all tables are present.
151/// Opens a write transaction and calls open_table on PATHINFO_TABLE, which will
152/// create it if not present.
153#[allow(clippy::result_large_err)]
154fn create_schema(db: &redb::Database) -> Result<(), Error> {
155    let txn = db.begin_write()?;
156    txn.open_table(PATHINFO_TABLE)?;
157    txn.commit()?;
158
159    Ok(())
160}
161
162#[async_trait]
163impl PathInfoService for RedbPathInfoService {
164    #[instrument(level = "trace", skip_all, fields(path_info.digest = BASE64.encode(&digest), instance_name = %self.instance_name))]
165    async fn get(&self, digest: [u8; 20]) -> Result<Option<PathInfo>, pathinfoservice::Error> {
166        let db = self.db.clone();
167
168        let path_info_bytes = match tokio::task::spawn_blocking({
169            move || -> Result<_, Error> {
170                let txn = db.begin_read()?;
171                let table = txn.open_table(PATHINFO_TABLE)?;
172                Ok(table.get(digest)?)
173            }
174        })
175        .await??
176        {
177            // The PathInfo was not found, return None.
178            None => return Ok(None),
179            Some(path_info_data) => path_info_data.value(),
180        };
181
182        let pathinfo_proto =
183            proto::PathInfo::decode(path_info_bytes.as_slice()).map_err(Error::ProtobufDecode)?;
184        let path_info = PathInfo::try_from(pathinfo_proto).map_err(Error::PathInfoValidation)?;
185
186        return Ok(Some(path_info));
187    }
188
189    #[instrument(level = "trace", skip_all, fields(path_info.root_node = ?path_info.node, instance_name = %self.instance_name))]
190    async fn put(&self, path_info: PathInfo) -> Result<PathInfo, pathinfoservice::Error> {
191        let db = self.db.clone();
192        tokio::task::spawn_blocking({
193            let path_info = path_info.clone();
194            move || -> Result<(), Error> {
195                let txn = db.begin_write()?;
196                {
197                    let mut table = txn.open_table(PATHINFO_TABLE)?;
198                    table.insert(
199                        *path_info.store_path.digest(),
200                        proto::PathInfo::from(path_info).encode_to_vec(),
201                    )?;
202                }
203                txn.commit()?;
204                Ok(())
205            }
206        })
207        .await??;
208
209        Ok(path_info)
210    }
211
212    fn list(&self) -> BoxStream<'static, Result<PathInfo, pathinfoservice::Error>> {
213        let db = self.db.clone();
214        let (tx, rx) = tokio::sync::mpsc::channel(64);
215
216        tokio::task::spawn_blocking(move || {
217            // IIFE to be able to use ? for the error cases
218            let result = (|| -> Result<(), Error> {
219                let read_txn = db.begin_read()?;
220
221                let table = read_txn.open_table(PATHINFO_TABLE)?;
222
223                let table_iter = table.iter()?;
224
225                for elem in table_iter {
226                    let path_info_proto = proto::PathInfo::decode(elem?.1.value().as_slice())?;
227
228                    let path_info = PathInfo::try_from(path_info_proto)?;
229
230                    if tx.blocking_send(Ok(path_info)).is_err() {
231                        break;
232                    }
233                }
234
235                Ok(())
236            })();
237
238            if let Err(err) = result {
239                let _ = tx.blocking_send(Err(err));
240            }
241        });
242
243        ReceiverStream::new(rx).err_into().boxed()
244    }
245}
246
247#[derive(thiserror::Error, Debug)]
248pub enum Error {
249    #[error("wrong arguments: {0}")]
250    WrongConfig(&'static str),
251    #[error("serde-qs error: {0}")]
252    SerdeQS(#[from] serde_qs::Error),
253
254    #[error("failed to decode protobuf: {0}")]
255    ProtobufDecode(#[from] prost::DecodeError),
256    #[error("failed to validate PathInfo: {0}")]
257    PathInfoValidation(#[from] crate::proto::ValidatePathInfoError),
258
259    #[error("unable to open write txn, database opened read-only")]
260    OpenedReadonly,
261
262    #[error("redb commit error: {0}")]
263    RedbCommit(#[from] redb::CommitError),
264    #[error("redb database error: {0}")]
265    RedbDatabase(#[from] redb::DatabaseError),
266    #[error("redb error: {0}")]
267    Redb(#[from] redb::Error),
268    #[error("redb storage error: {0}")]
269    RedbStorage(#[from] redb::StorageError),
270    #[error("redb table error: {0}")]
271    RedbTable(#[from] redb::TableError),
272    #[error("redb txn error: {0}")]
273    RedbTransaction(#[from] redb::TransactionError),
274
275    #[error("join error: {0}")]
276    TokioJoin(#[from] tokio::task::JoinError),
277    #[error("io error: {0}")]
278    IO(#[from] std::io::Error),
279}
280
281#[derive(Clone, Default, serde::Deserialize)]
282#[serde(deny_unknown_fields)]
283pub struct RedbPathInfoServiceConfig {
284    path: Option<PathBuf>,
285
286    /// The amount of memory (in bytes) used for caching data
287    cache_size: Option<usize>,
288
289    /// Whether to open read-only.
290    #[serde(default)]
291    read_only: bool,
292}
293
294impl TryFrom<url::Url> for RedbPathInfoServiceConfig {
295    type Error = Box<dyn std::error::Error + Send + Sync>;
296    fn try_from(url: url::Url) -> Result<Self, Self::Error> {
297        if url.has_host() {
298            return Err(Error::WrongConfig("no host allowed").into());
299        }
300
301        let path: Option<PathBuf> = match (url.scheme(), url.has_authority(), url.path()) {
302            ("redb+memory", false, "") => None,
303            ("redb+memory", false, _) => Err(Box::new(Error::WrongConfig(
304                "redb+memory with path is disallowed",
305            )))?,
306            ("redb+memory", true, _) => Err(Box::new(Error::WrongConfig(
307                "redb+memory may not have authority",
308            )))?,
309            ("redb", _, "") => Err(Box::new(Error::WrongConfig(
310                "redb without path is disallowed, use redb+memory if you want in-memory",
311            )))?,
312            ("redb", true, _path) => Err(Box::new(Error::WrongConfig("authority disallowed")))?,
313            ("redb", false, path) => Some(path.into()),
314            (_scheme, _, _) => Err(Box::new(Error::WrongConfig("unrecognized scheme")))?,
315        };
316
317        let mut config: RedbPathInfoServiceConfig =
318            serde_qs::from_str(url.query().unwrap_or_default())?;
319
320        config.path = path;
321
322        Ok(config)
323    }
324}
325
326#[async_trait]
327impl ServiceBuilder for RedbPathInfoServiceConfig {
328    type Output = dyn PathInfoService;
329    async fn build<'a>(
330        &'a self,
331        instance_name: &str,
332        _context: &CompositionContext,
333    ) -> Result<Arc<Self::Output>, Box<dyn std::error::Error + Send + Sync>> {
334        Ok(Arc::new(
335            RedbPathInfoService::new(instance_name.to_string(), self.to_owned()).await?,
336        ))
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use tempfile::TempDir;
343
344    use crate::fixtures::{DUMMY_PATH_DIGEST, PATH_INFO};
345    use crate::pathinfoservice::{PathInfoService, RedbPathInfoService, RedbPathInfoServiceConfig};
346
347    #[tokio::test]
348    async fn reopen_as_read_only() {
349        let tempdir = TempDir::new().unwrap();
350        let path = tempdir.path().join("data.redb");
351
352        let config = RedbPathInfoServiceConfig {
353            path: Some(path),
354            cache_size: None,
355            read_only: false,
356        };
357
358        // Create a read-write path info service and insert some data.
359        {
360            let path_info_service = RedbPathInfoService::new("rw".to_string(), config.clone())
361                .await
362                .expect("to construct");
363
364            path_info_service
365                .put(PATH_INFO.clone())
366                .await
367                .expect("to insert");
368        } // we drop the rw database here.
369
370        // Re-open the same path in ro mode (twice)
371        let ro_config = RedbPathInfoServiceConfig {
372            read_only: true,
373            ..config
374        };
375
376        let path_info_service_ro_1 = RedbPathInfoService::new("ro1".to_string(), ro_config.clone())
377            .await
378            .expect("to construct");
379        let path_info_service_ro_2 = RedbPathInfoService::new("ro2".to_string(), ro_config)
380            .await
381            .expect("to construct");
382
383        assert_eq!(
384            path_info_service_ro_1
385                .get(DUMMY_PATH_DIGEST)
386                .await
387                .expect("get to succeed")
388                .expect("to be Some(_)")
389                .store_path
390                .digest(),
391            &DUMMY_PATH_DIGEST,
392        );
393        assert_eq!(
394            path_info_service_ro_2
395                .get(DUMMY_PATH_DIGEST)
396                .await
397                .expect("get to succeed")
398                .expect("to be Some(_)")
399                .store_path
400                .digest(),
401            &DUMMY_PATH_DIGEST,
402        );
403    }
404
405    #[tokio::test]
406    async fn read_only_nonexistent() {
407        let tempdir = TempDir::new().unwrap();
408        let path = tempdir.path().join("data.redb");
409
410        let config = RedbPathInfoServiceConfig {
411            path: Some(path),
412            cache_size: None,
413            read_only: true,
414        };
415
416        // Opening a read-only redb should fail if the path doesn't exist.
417        assert!(
418            RedbPathInfoService::new("test".to_string(), config)
419                .await
420                .is_err(),
421            "opening new path r/o should fail"
422        );
423    }
424
425    #[tokio::test]
426    async fn open_rw_and_ro() {
427        let tempdir = TempDir::new().unwrap();
428        let path = tempdir.path().join("data.redb");
429
430        let config = RedbPathInfoServiceConfig {
431            path: Some(path),
432            cache_size: None,
433            read_only: false,
434        };
435
436        let _path_info_service = RedbPathInfoService::new("rw".to_string(), config.clone())
437            .await
438            .expect("to construct");
439
440        // Opening a read-only redb should fail if it's already opened read-write.
441        assert!(
442            RedbPathInfoService::new(
443                "ro".to_string(),
444                RedbPathInfoServiceConfig {
445                    read_only: true,
446                    ..config
447                }
448            )
449            .await
450            .is_err(),
451            "opening r/o should fail if still open r/w"
452        );
453    }
454}