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