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 db: Arc<Db>,
49}
50
51impl RedbDirectoryService {
52 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 #[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
138fn 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#[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 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 None => return Ok(None),
174 Some(directory_data) => directory_data.value(),
175 };
176
177 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 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 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 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 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 let db = self.db.clone();
280 let root_digest = tokio::task::spawn_blocking(move || {
281 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 {
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 cache_size: Option<usize>,
368
369 #[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 {
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 } 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 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 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}