snix_store/pathinfoservice/
redb.rs1use 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#[derive(Clone)]
40pub struct RedbPathInfoService {
41 instance_name: String,
42
43 db: Arc<Db>,
45}
46
47impl RedbPathInfoService {
48 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 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 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
143fn 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#[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 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 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 cache_size: Option<usize>,
288
289 #[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 {
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 } 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 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 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}