1use std::fmt;
12use std::path::PathBuf;
13use std::sync::Arc;
14use std::time::SystemTime;
15
16use async_trait::async_trait;
17use corium_log::{LogError, MemLogRegistry, TransactionLog, TxRecord, VersionedLog};
18#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
19use corium_log::{NativeLogStorage, NativeVersionedLog};
20use corium_store::{BlobId, BlobIdStream, BlobStore, FsStore, MemoryStore, RootStore, StoreError};
21
22#[cfg(feature = "postgres")]
23use corium_store::PostgresBlobStore;
24#[cfg(feature = "s3")]
25use corium_store::S3BlobStore;
26#[cfg(feature = "turso")]
27use corium_store::TursoBlobStore;
28
29#[derive(Clone, Default)]
31pub enum StoreSpec {
32 Memory,
36 #[default]
38 Fs,
39 #[cfg(feature = "postgres")]
41 Postgres {
42 connection_string: String,
44 },
45 #[cfg(feature = "turso")]
48 Turso {
49 path: String,
51 },
52 #[cfg(feature = "s3")]
54 S3 {
55 bucket: String,
57 prefix: String,
59 },
60}
61
62impl fmt::Debug for StoreSpec {
63 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
64 match self {
65 Self::Memory => formatter.write_str("Memory"),
66 Self::Fs => formatter.write_str("Fs"),
67 #[cfg(feature = "postgres")]
68 Self::Postgres { .. } => formatter
69 .debug_struct("Postgres")
70 .field("connection_string", &"[REDACTED]")
71 .finish(),
72 #[cfg(feature = "turso")]
73 Self::Turso { path } => formatter.debug_struct("Turso").field("path", path).finish(),
74 #[cfg(feature = "s3")]
75 Self::S3 { bucket, prefix } => formatter
76 .debug_struct("S3")
77 .field("bucket", bucket)
78 .field("prefix", prefix)
79 .finish(),
80 }
81 }
82}
83
84pub enum NodeStore {
89 Mem(MemoryStore),
91 Fs(FsStore),
93 #[cfg(feature = "postgres")]
95 Postgres(PostgresBlobStore),
96 #[cfg(feature = "turso")]
98 Turso(TursoBlobStore),
99 #[cfg(feature = "s3")]
101 S3(S3BlobStore),
102}
103
104impl NodeStore {
105 #[allow(clippy::unused_async)]
112 pub async fn open(spec: &StoreSpec, data_dir: &std::path::Path) -> Result<Self, StoreError> {
113 match spec {
114 StoreSpec::Memory => Ok(Self::Mem(MemoryStore::default())),
115 StoreSpec::Fs => Ok(Self::Fs(FsStore::open(data_dir.join("store"))?)),
116 #[cfg(feature = "postgres")]
117 StoreSpec::Postgres { connection_string } => Ok(Self::Postgres(
118 PostgresBlobStore::connect(connection_string).await?,
119 )),
120 #[cfg(feature = "turso")]
121 StoreSpec::Turso { path } => Ok(Self::Turso(TursoBlobStore::open(path).await?)),
122 #[cfg(feature = "s3")]
123 StoreSpec::S3 { bucket, prefix } => {
124 Ok(Self::S3(S3BlobStore::connect(bucket, prefix).await?))
125 }
126 }
127 }
128
129 #[allow(clippy::unused_async)]
135 pub async fn open_existing(
136 spec: &StoreSpec,
137 data_dir: &std::path::Path,
138 ) -> Result<Self, StoreError> {
139 match spec {
140 StoreSpec::Memory => Ok(Self::Mem(MemoryStore::default())),
141 StoreSpec::Fs => Ok(Self::Fs(FsStore::open(data_dir.join("store"))?)),
142 #[cfg(feature = "postgres")]
143 StoreSpec::Postgres { connection_string } => Ok(Self::Postgres(
144 PostgresBlobStore::connect_existing(connection_string).await?,
145 )),
146 #[cfg(feature = "turso")]
147 StoreSpec::Turso { path } => {
148 Ok(Self::Turso(TursoBlobStore::open_existing(path).await?))
149 }
150 #[cfg(feature = "s3")]
151 StoreSpec::S3 { bucket, prefix } => {
152 Ok(Self::S3(S3BlobStore::connect(bucket, prefix).await?))
153 }
154 }
155 }
156}
157
158#[async_trait]
159impl BlobStore for NodeStore {
160 async fn put(&self, bytes: &[u8]) -> Result<BlobId, StoreError> {
161 match self {
162 Self::Mem(store) => store.put(bytes).await,
163 Self::Fs(store) => store.put(bytes).await,
164 #[cfg(feature = "postgres")]
165 Self::Postgres(store) => store.put(bytes).await,
166 #[cfg(feature = "turso")]
167 Self::Turso(store) => store.put(bytes).await,
168 #[cfg(feature = "s3")]
169 Self::S3(store) => store.put(bytes).await,
170 }
171 }
172
173 async fn get(&self, id: &BlobId) -> Result<Option<Vec<u8>>, StoreError> {
174 match self {
175 Self::Mem(store) => store.get(id).await,
176 Self::Fs(store) => store.get(id).await,
177 #[cfg(feature = "postgres")]
178 Self::Postgres(store) => store.get(id).await,
179 #[cfg(feature = "turso")]
180 Self::Turso(store) => store.get(id).await,
181 #[cfg(feature = "s3")]
182 Self::S3(store) => store.get(id).await,
183 }
184 }
185
186 async fn contains(&self, id: &BlobId) -> Result<bool, StoreError> {
187 match self {
188 Self::Mem(store) => store.contains(id).await,
189 Self::Fs(store) => store.contains(id).await,
190 #[cfg(feature = "postgres")]
191 Self::Postgres(store) => store.contains(id).await,
192 #[cfg(feature = "turso")]
193 Self::Turso(store) => store.contains(id).await,
194 #[cfg(feature = "s3")]
195 Self::S3(store) => store.contains(id).await,
196 }
197 }
198
199 async fn delete(&self, id: &BlobId) -> Result<(), StoreError> {
200 match self {
201 Self::Mem(store) => store.delete(id).await,
202 Self::Fs(store) => store.delete(id).await,
203 #[cfg(feature = "postgres")]
204 Self::Postgres(store) => store.delete(id).await,
205 #[cfg(feature = "turso")]
206 Self::Turso(store) => store.delete(id).await,
207 #[cfg(feature = "s3")]
208 Self::S3(store) => store.delete(id).await,
209 }
210 }
211
212 async fn list(&self) -> Result<BlobIdStream, StoreError> {
213 match self {
214 Self::Mem(store) => store.list().await,
215 Self::Fs(store) => store.list().await,
216 #[cfg(feature = "postgres")]
217 Self::Postgres(store) => store.list().await,
218 #[cfg(feature = "turso")]
219 Self::Turso(store) => store.list().await,
220 #[cfg(feature = "s3")]
221 Self::S3(store) => store.list().await,
222 }
223 }
224
225 async fn modified_at(&self, id: &BlobId) -> Result<Option<SystemTime>, StoreError> {
226 match self {
227 Self::Mem(store) => store.modified_at(id).await,
228 Self::Fs(store) => store.modified_at(id).await,
229 #[cfg(feature = "postgres")]
230 Self::Postgres(store) => store.modified_at(id).await,
231 #[cfg(feature = "turso")]
232 Self::Turso(store) => store.modified_at(id).await,
233 #[cfg(feature = "s3")]
234 Self::S3(store) => store.modified_at(id).await,
235 }
236 }
237}
238
239#[async_trait]
240impl RootStore for NodeStore {
241 async fn get_root(&self, name: &str) -> Result<Option<Vec<u8>>, StoreError> {
242 match self {
243 Self::Mem(store) => store.get_root(name).await,
244 Self::Fs(store) => store.get_root(name).await,
245 #[cfg(feature = "postgres")]
246 Self::Postgres(store) => store.get_root(name).await,
247 #[cfg(feature = "turso")]
248 Self::Turso(store) => store.get_root(name).await,
249 #[cfg(feature = "s3")]
250 Self::S3(store) => store.get_root(name).await,
251 }
252 }
253
254 async fn cas_root(
255 &self,
256 name: &str,
257 expected: Option<&[u8]>,
258 new: &[u8],
259 ) -> Result<(), StoreError> {
260 match self {
261 Self::Mem(store) => store.cas_root(name, expected, new).await,
262 Self::Fs(store) => store.cas_root(name, expected, new).await,
263 #[cfg(feature = "postgres")]
264 Self::Postgres(store) => store.cas_root(name, expected, new).await,
265 #[cfg(feature = "turso")]
266 Self::Turso(store) => store.cas_root(name, expected, new).await,
267 #[cfg(feature = "s3")]
268 Self::S3(store) => store.cas_root(name, expected, new).await,
269 }
270 }
271
272 async fn delete_root(&self, name: &str) -> Result<(), StoreError> {
273 match self {
274 Self::Mem(store) => store.delete_root(name).await,
275 Self::Fs(store) => store.delete_root(name).await,
276 #[cfg(feature = "postgres")]
277 Self::Postgres(store) => store.delete_root(name).await,
278 #[cfg(feature = "turso")]
279 Self::Turso(store) => store.delete_root(name).await,
280 #[cfg(feature = "s3")]
281 Self::S3(store) => store.delete_root(name).await,
282 }
283 }
284
285 async fn list_roots(&self, prefix: &str) -> Result<Vec<String>, StoreError> {
286 match self {
287 Self::Mem(store) => store.list_roots(prefix).await,
288 Self::Fs(store) => store.list_roots(prefix).await,
289 #[cfg(feature = "postgres")]
290 Self::Postgres(store) => store.list_roots(prefix).await,
291 #[cfg(feature = "turso")]
292 Self::Turso(store) => store.list_roots(prefix).await,
293 #[cfg(feature = "s3")]
294 Self::S3(store) => store.list_roots(prefix).await,
295 }
296 }
297}
298
299pub enum LogBackend {
301 Fs(PathBuf),
303 Mem(MemLogRegistry),
305 #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
307 Native(Arc<dyn NativeLogStorage>),
308}
309
310struct BlockingTransactionLog(Arc<dyn TransactionLog>);
313
314#[async_trait]
315impl TransactionLog for BlockingTransactionLog {
316 fn append(&self, record: &TxRecord) -> Result<(), LogError> {
317 self.0.append(record)
318 }
319
320 async fn append_async(&self, record: &TxRecord) -> Result<(), LogError> {
321 let log = Arc::clone(&self.0);
322 let record = record.clone();
323 tokio::task::spawn_blocking(move || log.append(&record))
324 .await
325 .map_err(|error| LogError::Native(format!("log task failed: {error}")))?
326 }
327
328 fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
329 self.0.tx_range(start, end)
330 }
331
332 async fn tx_range_async(
333 &self,
334 start: u64,
335 end: Option<u64>,
336 ) -> Result<Vec<TxRecord>, LogError> {
337 let log = Arc::clone(&self.0);
338 tokio::task::spawn_blocking(move || log.tx_range(start, end))
339 .await
340 .map_err(|error| LogError::Native(format!("log task failed: {error}")))?
341 }
342}
343
344impl LogBackend {
345 #[must_use]
347 #[allow(clippy::needless_pass_by_value)]
348 pub fn for_spec(
349 spec: &StoreSpec,
350 data_dir: &std::path::Path,
351 #[cfg_attr(
352 not(any(feature = "postgres", feature = "turso", feature = "s3")),
353 allow(unused_variables)
354 )]
355 store: Arc<NodeStore>,
356 ) -> Self {
357 match spec {
358 StoreSpec::Memory => Self::Mem(MemLogRegistry::new()),
359 StoreSpec::Fs => Self::Fs(data_dir.join("logs")),
360 #[cfg(feature = "postgres")]
361 StoreSpec::Postgres { .. } => Self::Native(Arc::new(NativeRootLogStore::new(store))),
362 #[cfg(feature = "turso")]
363 StoreSpec::Turso { .. } => Self::Native(Arc::new(NativeRootLogStore::new(store))),
364 #[cfg(feature = "s3")]
365 StoreSpec::S3 { .. } => Self::Native(Arc::new(NativeRootLogStore::new(store))),
366 }
367 }
368
369 pub async fn open(
374 &self,
375 name: &str,
376 write_version: u64,
377 ) -> Result<Arc<dyn TransactionLog>, LogError> {
378 match self {
379 Self::Fs(dir) => {
380 let dir = dir.clone();
381 let name = name.to_owned();
382 let log = tokio::task::spawn_blocking(move || {
383 VersionedLog::open(dir, &name, write_version)
384 })
385 .await
386 .map_err(|error| LogError::Native(format!("log task failed: {error}")))??;
387 Ok(Arc::new(BlockingTransactionLog(Arc::new(log))))
388 }
389 Self::Mem(registry) => Ok(Arc::new(registry.open(name, write_version))),
390 #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
391 Self::Native(storage) => Ok(Arc::new(
392 NativeVersionedLog::open(Arc::clone(storage), name, write_version).await?,
393 )),
394 }
395 }
396
397 #[must_use]
399 pub async fn exists(&self, name: &str) -> bool {
400 match self {
401 Self::Fs(dir) => {
402 let dir = dir.clone();
403 let name = name.to_owned();
404 tokio::task::spawn_blocking(move || VersionedLog::exists(dir, &name))
405 .await
406 .unwrap_or(false)
407 }
408 Self::Mem(registry) => registry.exists(name),
409 #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
410 Self::Native(storage) => storage
411 .list_chunks(name)
412 .await
413 .is_ok_and(|chunks| !chunks.is_empty()),
414 }
415 }
416
417 pub async fn delete_all(&self, name: &str) -> Result<(), LogError> {
422 match self {
423 Self::Fs(dir) => {
424 let dir = dir.clone();
425 let name = name.to_owned();
426 tokio::task::spawn_blocking(move || VersionedLog::delete_all(dir, &name))
427 .await
428 .map_err(|error| LogError::Native(format!("log task failed: {error}")))?
429 }
430 Self::Mem(registry) => {
431 registry.delete_all(name);
432 Ok(())
433 }
434 #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
435 Self::Native(storage) => storage.delete_all(name).await,
436 }
437 }
438}
439
440#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
441struct NativeRootLogStore {
442 store: Arc<NodeStore>,
443}
444
445#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
446impl NativeRootLogStore {
447 fn new(store: Arc<NodeStore>) -> Self {
448 Self { store }
449 }
450}
451
452#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
453impl NativeRootLogStore {
454 fn key(name: &str, version: u64, chunk: u64) -> String {
458 if chunk == 0 {
459 format!("log:{name}:v{version:020}")
460 } else {
461 format!("log:{name}:v{version:020}:c{chunk:020}")
462 }
463 }
464
465 fn prefix(name: &str) -> String {
466 format!("log:{name}:v")
467 }
468
469 fn parse_key(prefix: &str, key: &str) -> Option<(u64, u64)> {
472 let rest = key.strip_prefix(prefix)?;
473 match rest.split_once(":c") {
474 Some((version, chunk)) => Some((version.parse().ok()?, chunk.parse().ok()?)),
475 None => Some((rest.parse().ok()?, 0)),
476 }
477 }
478}
479
480#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
481#[async_trait]
482impl NativeLogStorage for NativeRootLogStore {
483 async fn read_chunk(
484 &self,
485 name: &str,
486 version: u64,
487 chunk: u64,
488 ) -> Result<Option<Vec<u8>>, LogError> {
489 self.store
490 .get_root(&Self::key(name, version, chunk))
491 .await
492 .map_err(|error| LogError::Native(error.to_string()))
493 }
494
495 async fn cas_chunk(
496 &self,
497 name: &str,
498 version: u64,
499 chunk: u64,
500 expected: Option<&[u8]>,
501 new: &[u8],
502 ) -> Result<(), LogError> {
503 self.store
504 .cas_root(&Self::key(name, version, chunk), expected, new)
505 .await
506 .map_err(|error| LogError::Native(error.to_string()))
507 }
508
509 async fn list_chunks(&self, name: &str) -> Result<Vec<(u64, u64)>, LogError> {
510 let prefix = Self::prefix(name);
511 let names = self
512 .store
513 .list_roots(&prefix)
514 .await
515 .map_err(|error| LogError::Native(error.to_string()))?;
516 names
517 .into_iter()
518 .map(|key| Self::parse_key(&prefix, &key).ok_or(LogError::Corrupt))
519 .collect()
520 }
521
522 async fn delete_all(&self, name: &str) -> Result<(), LogError> {
523 for (version, chunk) in self.list_chunks(name).await? {
524 self.store
525 .delete_root(&Self::key(name, version, chunk))
526 .await
527 .map_err(|error| LogError::Native(error.to_string()))?;
528 }
529 Ok(())
530 }
531}
532
533#[cfg(all(test, any(feature = "postgres", feature = "turso", feature = "s3")))]
534mod tests {
535 use super::*;
536 use corium_core::{Datom, EntityId, Value};
537
538 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
539 async fn native_root_log_crosses_chunk_boundaries_on_the_runtime() {
540 let run = async {
541 let dir = tempfile::tempdir().expect("tempdir");
542 let store = Arc::new(
543 NodeStore::open(&StoreSpec::Memory, dir.path())
544 .await
545 .expect("memory store"),
546 );
547 let storage = Arc::new(NativeRootLogStore::new(store));
548 let log = NativeVersionedLog::open(Arc::clone(&storage), "db", 1)
549 .await
550 .expect("open native log");
551 for t in 1..=3 {
552 log.append_async(&TxRecord {
553 t,
554 tx_instant: i64::try_from(t).expect("small t"),
555 datoms: vec![Datom {
556 e: EntityId::from_raw(t),
557 a: EntityId::from_raw(1),
558 v: Value::Bytes(vec![0; 300 * 1024].into()),
559 tx: EntityId::from_raw(t),
560 added: true,
561 }],
562 })
563 .await
564 .expect("append");
565 }
566 let chunks = storage.list_chunks("db").await.expect("list chunks");
567 assert_eq!(chunks.len(), 3);
568 assert_eq!(log.replay_async().await.expect("replay").len(), 3);
569 };
570 tokio::time::timeout(std::time::Duration::from_secs(5), run)
571 .await
572 .expect("native log operation stalled on its runtime");
573 }
574}