1use std::fmt;
12use std::path::PathBuf;
13use std::sync::Arc;
14use std::time::SystemTime;
15#[cfg(feature = "s3")]
16use std::time::{Duration, UNIX_EPOCH};
17
18use async_trait::async_trait;
19use corium_log::{LogCipher, LogError, MemLogRegistry, TransactionLog, TxRecord, VersionedLog};
20#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
21use corium_log::{NativeLogStorage, NativeVersionedLog};
22pub use corium_store::StorageConnectionError;
23use corium_store::{
24 BlobId, BlobIdStream, BlobStore, DiscoveredStore, DiscoveredStoreSpec, FsStore, MemoryStore,
25 RootStore, StoreError,
26};
27
28#[cfg(feature = "postgres")]
29use corium_store::PostgresBlobStore;
30#[cfg(feature = "turso")]
31use corium_store::TursoBlobStore;
32#[cfg(feature = "s3")]
33use corium_store::{S3BlobStore, S3ClientConfig, normalize_s3_prefix};
34
35#[derive(Clone, Default)]
37pub enum StoreSpec {
38 Memory,
42 #[default]
44 Fs,
45 #[cfg(feature = "postgres")]
47 Postgres {
48 connection_string: String,
50 },
51 #[cfg(feature = "turso")]
54 Turso {
55 path: String,
57 },
58 #[cfg(feature = "s3")]
60 S3 {
61 bucket: String,
63 prefix: String,
65 client: S3ClientConfig,
68 },
69}
70
71impl fmt::Debug for StoreSpec {
72 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73 match self {
74 Self::Memory => formatter.write_str("Memory"),
75 Self::Fs => formatter.write_str("Fs"),
76 #[cfg(feature = "postgres")]
77 Self::Postgres { .. } => formatter
78 .debug_struct("Postgres")
79 .field("connection_string", &"[REDACTED]")
80 .finish(),
81 #[cfg(feature = "turso")]
82 Self::Turso { path } => formatter.debug_struct("Turso").field("path", path).finish(),
83 #[cfg(feature = "s3")]
84 Self::S3 {
85 bucket,
86 prefix,
87 client,
88 } => formatter
89 .debug_struct("S3")
90 .field("bucket", bucket)
91 .field("prefix", prefix)
92 .field("client", client)
93 .finish(),
94 }
95 }
96}
97
98#[cfg(feature = "s3")]
100#[derive(Clone)]
101pub enum S3ReadOnlyCredentials {
102 Static {
105 access_key_id: String,
107 secret_access_key: String,
109 session_token: Option<String>,
111 },
112 AssumeRole {
118 role_arn: String,
120 session_name: String,
122 duration_seconds: i32,
124 external_id: Option<String>,
126 },
127}
128
129#[cfg(feature = "s3")]
130impl fmt::Debug for S3ReadOnlyCredentials {
131 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
132 match self {
133 Self::Static { .. } => formatter.write_str("Static([REDACTED])"),
134 Self::AssumeRole {
135 role_arn,
136 session_name,
137 duration_seconds,
138 external_id,
139 } => formatter
140 .debug_struct("AssumeRole")
141 .field("role_arn", role_arn)
142 .field("session_name", session_name)
143 .field("duration_seconds", duration_seconds)
144 .field("external_id", &external_id.as_ref().map(|_| "[REDACTED]"))
145 .finish(),
146 }
147 }
148}
149
150#[cfg(feature = "s3")]
152#[derive(Clone)]
153pub struct S3ReadOnlyConfig {
154 pub region: Option<String>,
156 pub endpoint_url: Option<String>,
158 pub credentials: S3ReadOnlyCredentials,
160 runtime: Arc<S3ReadOnlyRuntime>,
161}
162
163#[cfg(feature = "s3")]
164impl fmt::Debug for S3ReadOnlyConfig {
165 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
166 formatter
167 .debug_struct("S3ReadOnlyConfig")
168 .field("region", &self.region)
169 .field("endpoint_url", &self.endpoint_url)
170 .field("credentials", &self.credentials)
171 .finish_non_exhaustive()
172 }
173}
174
175#[cfg(feature = "s3")]
176#[derive(Default)]
177struct S3ReadOnlyRuntime {
178 aws: tokio::sync::OnceCell<ResolvedAwsConfig>,
179 cached_credentials: tokio::sync::Mutex<Option<CachedS3Credentials>>,
180}
181
182#[cfg(feature = "s3")]
183struct ResolvedAwsConfig {
184 sts: aws_sdk_sts::Client,
185 region: Option<String>,
186 endpoint_url: Option<String>,
187}
188
189#[cfg(feature = "s3")]
190#[derive(Clone)]
191struct CachedS3Credentials {
192 access_key_id: String,
193 secret_access_key: String,
194 session_token: String,
195 expires_unix_seconds: i64,
196 refresh_at: SystemTime,
197}
198
199#[derive(Clone, Default)]
205pub struct StorageInfoConfig {
206 pub postgres_connection_string: Option<String>,
208 #[cfg(feature = "s3")]
210 pub s3: Option<S3ReadOnlyConfig>,
211}
212
213impl fmt::Debug for StorageInfoConfig {
214 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
215 let mut debug = formatter.debug_struct("StorageInfoConfig");
216 debug.field(
217 "postgres_connection_string",
218 &self
219 .postgres_connection_string
220 .as_ref()
221 .map(|_| "[REDACTED]"),
222 );
223 #[cfg(feature = "s3")]
224 debug.field("s3", &self.s3);
225 debug.finish()
226 }
227}
228
229impl StoreSpec {
230 pub(crate) fn from_discovered(
231 discovered: &DiscoveredStoreSpec,
232 ) -> Result<(Self, PathBuf), StorageConnectionError> {
233 #[allow(unreachable_patterns)]
236 let resolved = match discovered {
237 DiscoveredStoreSpec::Filesystem { root } => {
238 let data_dir = root
239 .parent()
240 .map(std::path::Path::to_path_buf)
241 .unwrap_or_default();
242 (Self::Fs, data_dir)
243 }
244 #[cfg(feature = "postgres")]
245 DiscoveredStoreSpec::Postgres { connection_string } => (
246 Self::Postgres {
247 connection_string: connection_string.clone(),
248 },
249 PathBuf::new(),
250 ),
251 #[cfg(feature = "turso")]
252 DiscoveredStoreSpec::Turso { path } => (
253 Self::Turso {
254 path: path.to_string_lossy().into_owned(),
255 },
256 PathBuf::new(),
257 ),
258 #[cfg(feature = "s3")]
259 DiscoveredStoreSpec::S3 {
260 bucket,
261 prefix,
262 client,
263 } => (
264 Self::S3 {
265 bucket: bucket.clone(),
266 prefix: prefix.clone(),
267 client: client.clone(),
268 },
269 PathBuf::new(),
270 ),
271 _ => {
272 return Err(StorageConnectionError::Unsupported(
273 "this corium-transactor build lacks the advertised storage backend".into(),
274 ));
275 }
276 };
277 Ok(resolved)
278 }
279
280 #[cfg_attr(not(feature = "s3"), allow(clippy::unused_async))]
290 pub async fn connection_info(
291 &self,
292 data_dir: &std::path::Path,
293 #[cfg_attr(
294 not(any(feature = "postgres", feature = "s3")),
295 allow(unused_variables)
296 )]
297 read_only: &StorageInfoConfig,
298 ) -> Result<corium_protocol::pb::StorageConnection, String> {
299 use corium_protocol::pb;
300 use pb::storage_connection::Backend;
301
302 fn absolute(path: &std::path::Path) -> Result<String, String> {
303 let path = if path.is_absolute() {
304 path.to_path_buf()
305 } else {
306 std::env::current_dir()
307 .map_err(|error| error.to_string())?
308 .join(path)
309 };
310 path.into_os_string()
311 .into_string()
312 .map_err(|_| "storage path is not valid UTF-8".to_owned())
313 }
314
315 let backend = match self {
316 Self::Memory => Backend::Memory(pb::MemoryStorage {}),
317 Self::Fs => Backend::Filesystem(pb::FilesystemStorage {
318 data_dir: absolute(data_dir)?,
319 }),
320 #[cfg(feature = "postgres")]
321 Self::Postgres { .. } => {
322 let connection_string =
323 read_only
324 .postgres_connection_string
325 .clone()
326 .ok_or_else(|| {
327 "PostgreSQL storage discovery requires a separately configured \
328 read-only connection string"
329 .to_owned()
330 })?;
331 Backend::Postgres(pb::PostgreSqlStorage { connection_string })
332 }
333 #[cfg(feature = "turso")]
334 Self::Turso { path } => Backend::Turso(pb::TursoStorage {
335 path: absolute(std::path::Path::new(path))?,
336 }),
337 #[cfg(feature = "s3")]
338 Self::S3 { bucket, prefix, .. } => {
339 let read_only = read_only.s3.as_ref().ok_or_else(|| {
340 "S3 storage discovery requires separately configured read-only credentials"
341 .to_owned()
342 })?;
343 Backend::S3(read_only.s3_storage(bucket, prefix).await?)
344 }
345 };
346 Ok(pb::StorageConnection {
347 backend: Some(backend),
348 })
349 }
350}
351
352#[cfg(feature = "s3")]
353fn unix_timestamp(seconds: i64) -> Option<SystemTime> {
354 u64::try_from(seconds)
355 .ok()
356 .filter(|seconds| *seconds > 0)
357 .and_then(|seconds| UNIX_EPOCH.checked_add(Duration::from_secs(seconds)))
358}
359
360#[cfg(feature = "s3")]
361impl S3ReadOnlyConfig {
362 #[must_use]
365 pub fn new(
366 region: Option<String>,
367 endpoint_url: Option<String>,
368 credentials: S3ReadOnlyCredentials,
369 ) -> Self {
370 Self {
371 region,
372 endpoint_url,
373 credentials,
374 runtime: Arc::new(S3ReadOnlyRuntime::default()),
375 }
376 }
377
378 async fn aws(&self) -> &ResolvedAwsConfig {
379 self.runtime
380 .aws
381 .get_or_init(|| async {
382 let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
383 if let Some(region) = &self.region {
384 loader = loader.region(aws_sdk_sts::config::Region::new(region.clone()));
385 }
386 let config = loader.load().await;
387 let region = self
388 .region
389 .clone()
390 .or_else(|| config.region().map(ToString::to_string));
391 let endpoint_url = self.endpoint_url.clone().or_else(ambient_s3_endpoint);
392 ResolvedAwsConfig {
393 sts: aws_sdk_sts::Client::new(&config),
394 region,
395 endpoint_url,
396 }
397 })
398 .await
399 }
400
401 pub(crate) async fn initialize(&self) {
402 self.aws().await;
403 }
404
405 async fn s3_storage(
406 &self,
407 bucket: &str,
408 prefix: &str,
409 ) -> Result<corium_protocol::pb::S3Storage, String> {
410 let prefix = normalize_s3_prefix(prefix.to_owned());
411 let aws = self.aws().await;
412 let (access_key_id, secret_access_key, session_token, expires_unix_seconds) =
413 match &self.credentials {
414 S3ReadOnlyCredentials::Static {
415 access_key_id,
416 secret_access_key,
417 session_token,
418 } => (
419 access_key_id.clone(),
420 secret_access_key.clone(),
421 session_token.clone().unwrap_or_default(),
422 0,
423 ),
424 S3ReadOnlyCredentials::AssumeRole {
425 role_arn,
426 session_name,
427 duration_seconds,
428 external_id,
429 } => {
430 let issued = self
431 .assume_role_credentials(
432 bucket,
433 &prefix,
434 role_arn,
435 session_name,
436 *duration_seconds,
437 external_id.as_deref(),
438 )
439 .await?;
440 (
441 issued.access_key_id,
442 issued.secret_access_key,
443 issued.session_token,
444 issued.expires_unix_seconds,
445 )
446 }
447 };
448 Ok(corium_protocol::pb::S3Storage {
449 bucket: bucket.to_owned(),
450 prefix,
451 access_key_id,
452 secret_access_key,
453 session_token,
454 region: aws.region.clone().unwrap_or_default(),
455 endpoint_url: aws.endpoint_url.clone().unwrap_or_default(),
456 expires_unix_seconds,
457 })
458 }
459
460 async fn assume_role_credentials(
461 &self,
462 bucket: &str,
463 prefix: &str,
464 role_arn: &str,
465 session_name: &str,
466 duration_seconds: i32,
467 external_id: Option<&str>,
468 ) -> Result<CachedS3Credentials, String> {
469 let mut cached = self.runtime.cached_credentials.lock().await;
470 if let Some(credentials) = cached
471 .as_ref()
472 .filter(|credentials| SystemTime::now() < credentials.refresh_at)
473 {
474 return Ok(credentials.clone());
475 }
476 let partition = role_arn.split(':').nth(1).unwrap_or("aws");
477 let policy = serde_json::json!({
478 "Version": "2012-10-17",
479 "Statement": [
480 {
481 "Effect": "Allow",
482 "Action": ["s3:GetObject"],
483 "Resource": [format!("arn:{partition}:s3:::{bucket}/{prefix}*")]
484 },
485 {
486 "Effect": "Allow",
487 "Action": ["s3:ListBucket"],
488 "Resource": [format!("arn:{partition}:s3:::{bucket}")],
489 "Condition": {
490 "StringLike": {
491 "s3:prefix": [format!("{prefix}*")]
492 }
493 }
494 }
495 ]
496 })
497 .to_string();
498 let issued_at = SystemTime::now();
499 let output = self
500 .aws()
501 .await
502 .sts
503 .assume_role()
504 .role_arn(role_arn)
505 .role_session_name(session_name)
506 .duration_seconds(duration_seconds)
507 .policy(policy)
508 .set_external_id(external_id.map(str::to_owned))
509 .send()
510 .await
511 .map_err(|error| format!("cannot assume S3 read-only role: {error}"))?;
512 let credentials = output.credentials().ok_or_else(|| {
513 "AWS STS returned no credentials for the S3 read-only role".to_owned()
514 })?;
515 let expires_unix_seconds = credentials.expiration().secs();
516 let expiration = unix_timestamp(expires_unix_seconds).unwrap_or(issued_at);
517 let lifetime = expiration
518 .duration_since(issued_at)
519 .unwrap_or(Duration::ZERO);
520 let issued = CachedS3Credentials {
521 access_key_id: credentials.access_key_id().to_owned(),
522 secret_access_key: credentials.secret_access_key().to_owned(),
523 session_token: credentials.session_token().to_owned(),
524 expires_unix_seconds,
525 refresh_at: issued_at
526 .checked_add(lifetime.mul_f64(0.8))
527 .unwrap_or(issued_at),
528 };
529 *cached = Some(issued.clone());
530 Ok(issued)
531 }
532}
533
534#[cfg(feature = "s3")]
535fn ambient_s3_endpoint() -> Option<String> {
536 std::env::var("AWS_ENDPOINT_URL_S3")
537 .ok()
538 .filter(|value| !value.is_empty())
539 .or_else(|| {
540 std::env::var("AWS_ENDPOINT_URL")
541 .ok()
542 .filter(|value| !value.is_empty())
543 })
544}
545
546impl StorageInfoConfig {
547 #[cfg_attr(not(feature = "s3"), allow(clippy::unused_async))]
548 pub(crate) async fn initialize(&self) {
549 #[cfg(feature = "s3")]
550 if let Some(s3) = &self.s3 {
551 s3.initialize().await;
552 }
553 }
554}
555
556pub enum NodeStore {
561 Mem(MemoryStore),
563 Fs(FsStore),
565 #[cfg(feature = "postgres")]
567 Postgres(PostgresBlobStore),
568 #[cfg(feature = "turso")]
570 Turso(TursoBlobStore),
571 #[cfg(feature = "s3")]
573 S3(S3BlobStore),
574}
575
576impl NodeStore {
577 #[allow(clippy::unused_async)]
584 pub async fn open(spec: &StoreSpec, data_dir: &std::path::Path) -> Result<Self, StoreError> {
585 match spec {
586 StoreSpec::Memory => Ok(Self::Mem(MemoryStore::default())),
587 StoreSpec::Fs => Ok(Self::Fs(FsStore::open(data_dir.join("store"))?)),
588 #[cfg(feature = "postgres")]
589 StoreSpec::Postgres { connection_string } => Ok(Self::Postgres(
590 PostgresBlobStore::connect(connection_string).await?,
591 )),
592 #[cfg(feature = "turso")]
593 StoreSpec::Turso { path } => Ok(Self::Turso(TursoBlobStore::open(path).await?)),
594 #[cfg(feature = "s3")]
595 StoreSpec::S3 {
596 bucket,
597 prefix,
598 client,
599 } => Ok(Self::S3(
600 S3BlobStore::connect_with_config(bucket, prefix, client).await?,
601 )),
602 }
603 }
604}
605
606#[async_trait]
607impl BlobStore for NodeStore {
608 async fn put(&self, bytes: &[u8]) -> Result<BlobId, StoreError> {
609 match self {
610 Self::Mem(store) => store.put(bytes).await,
611 Self::Fs(store) => store.put(bytes).await,
612 #[cfg(feature = "postgres")]
613 Self::Postgres(store) => store.put(bytes).await,
614 #[cfg(feature = "turso")]
615 Self::Turso(store) => store.put(bytes).await,
616 #[cfg(feature = "s3")]
617 Self::S3(store) => store.put(bytes).await,
618 }
619 }
620
621 async fn get(&self, id: &BlobId) -> Result<Option<Vec<u8>>, StoreError> {
622 match self {
623 Self::Mem(store) => store.get(id).await,
624 Self::Fs(store) => store.get(id).await,
625 #[cfg(feature = "postgres")]
626 Self::Postgres(store) => store.get(id).await,
627 #[cfg(feature = "turso")]
628 Self::Turso(store) => store.get(id).await,
629 #[cfg(feature = "s3")]
630 Self::S3(store) => store.get(id).await,
631 }
632 }
633
634 async fn contains(&self, id: &BlobId) -> Result<bool, StoreError> {
635 match self {
636 Self::Mem(store) => store.contains(id).await,
637 Self::Fs(store) => store.contains(id).await,
638 #[cfg(feature = "postgres")]
639 Self::Postgres(store) => store.contains(id).await,
640 #[cfg(feature = "turso")]
641 Self::Turso(store) => store.contains(id).await,
642 #[cfg(feature = "s3")]
643 Self::S3(store) => store.contains(id).await,
644 }
645 }
646
647 async fn delete(&self, id: &BlobId) -> Result<(), StoreError> {
648 match self {
649 Self::Mem(store) => store.delete(id).await,
650 Self::Fs(store) => store.delete(id).await,
651 #[cfg(feature = "postgres")]
652 Self::Postgres(store) => store.delete(id).await,
653 #[cfg(feature = "turso")]
654 Self::Turso(store) => store.delete(id).await,
655 #[cfg(feature = "s3")]
656 Self::S3(store) => store.delete(id).await,
657 }
658 }
659
660 async fn list(&self) -> Result<BlobIdStream, StoreError> {
661 match self {
662 Self::Mem(store) => store.list().await,
663 Self::Fs(store) => store.list().await,
664 #[cfg(feature = "postgres")]
665 Self::Postgres(store) => store.list().await,
666 #[cfg(feature = "turso")]
667 Self::Turso(store) => store.list().await,
668 #[cfg(feature = "s3")]
669 Self::S3(store) => store.list().await,
670 }
671 }
672
673 async fn modified_at(&self, id: &BlobId) -> Result<Option<SystemTime>, StoreError> {
674 match self {
675 Self::Mem(store) => store.modified_at(id).await,
676 Self::Fs(store) => store.modified_at(id).await,
677 #[cfg(feature = "postgres")]
678 Self::Postgres(store) => store.modified_at(id).await,
679 #[cfg(feature = "turso")]
680 Self::Turso(store) => store.modified_at(id).await,
681 #[cfg(feature = "s3")]
682 Self::S3(store) => store.modified_at(id).await,
683 }
684 }
685}
686
687#[async_trait]
688impl RootStore for NodeStore {
689 async fn get_root(&self, name: &str) -> Result<Option<Vec<u8>>, StoreError> {
690 match self {
691 Self::Mem(store) => store.get_root(name).await,
692 Self::Fs(store) => store.get_root(name).await,
693 #[cfg(feature = "postgres")]
694 Self::Postgres(store) => store.get_root(name).await,
695 #[cfg(feature = "turso")]
696 Self::Turso(store) => store.get_root(name).await,
697 #[cfg(feature = "s3")]
698 Self::S3(store) => store.get_root(name).await,
699 }
700 }
701
702 async fn cas_root(
703 &self,
704 name: &str,
705 expected: Option<&[u8]>,
706 new: &[u8],
707 ) -> Result<(), StoreError> {
708 match self {
709 Self::Mem(store) => store.cas_root(name, expected, new).await,
710 Self::Fs(store) => store.cas_root(name, expected, new).await,
711 #[cfg(feature = "postgres")]
712 Self::Postgres(store) => store.cas_root(name, expected, new).await,
713 #[cfg(feature = "turso")]
714 Self::Turso(store) => store.cas_root(name, expected, new).await,
715 #[cfg(feature = "s3")]
716 Self::S3(store) => store.cas_root(name, expected, new).await,
717 }
718 }
719
720 async fn delete_root(&self, name: &str) -> Result<(), StoreError> {
721 match self {
722 Self::Mem(store) => store.delete_root(name).await,
723 Self::Fs(store) => store.delete_root(name).await,
724 #[cfg(feature = "postgres")]
725 Self::Postgres(store) => store.delete_root(name).await,
726 #[cfg(feature = "turso")]
727 Self::Turso(store) => store.delete_root(name).await,
728 #[cfg(feature = "s3")]
729 Self::S3(store) => store.delete_root(name).await,
730 }
731 }
732
733 async fn list_roots(&self, prefix: &str) -> Result<Vec<String>, StoreError> {
734 match self {
735 Self::Mem(store) => store.list_roots(prefix).await,
736 Self::Fs(store) => store.list_roots(prefix).await,
737 #[cfg(feature = "postgres")]
738 Self::Postgres(store) => store.list_roots(prefix).await,
739 #[cfg(feature = "turso")]
740 Self::Turso(store) => store.list_roots(prefix).await,
741 #[cfg(feature = "s3")]
742 Self::S3(store) => store.list_roots(prefix).await,
743 }
744 }
745}
746
747pub enum LogBackend {
749 Fs(PathBuf),
751 Mem(MemLogRegistry),
753 #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
755 Native(Arc<dyn NativeLogStorage>),
756}
757
758struct BlockingTransactionLog(Arc<dyn TransactionLog>);
761
762#[async_trait]
763impl TransactionLog for BlockingTransactionLog {
764 fn append(&self, record: &TxRecord) -> Result<(), LogError> {
765 self.0.append(record)
766 }
767
768 async fn append_async(&self, record: &TxRecord) -> Result<(), LogError> {
769 let log = Arc::clone(&self.0);
770 let record = record.clone();
771 tokio::task::spawn_blocking(move || log.append(&record))
772 .await
773 .map_err(|error| LogError::Native(format!("log task failed: {error}")))?
774 }
775
776 fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
777 self.0.tx_range(start, end)
778 }
779
780 async fn tx_range_async(
781 &self,
782 start: u64,
783 end: Option<u64>,
784 ) -> Result<Vec<TxRecord>, LogError> {
785 let log = Arc::clone(&self.0);
786 tokio::task::spawn_blocking(move || log.tx_range(start, end))
787 .await
788 .map_err(|error| LogError::Native(format!("log task failed: {error}")))?
789 }
790}
791
792struct ReadOnlyTransactionLog(Arc<dyn TransactionLog>);
795
796#[async_trait]
797impl TransactionLog for ReadOnlyTransactionLog {
798 fn append(&self, _record: &TxRecord) -> Result<(), LogError> {
799 Err(LogError::Native("transaction log is read-only".into()))
800 }
801
802 fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
803 self.0.tx_range(start, end)
804 }
805
806 async fn tx_range_async(
807 &self,
808 start: u64,
809 end: Option<u64>,
810 ) -> Result<Vec<TxRecord>, LogError> {
811 self.0.tx_range_async(start, end).await
812 }
813}
814
815impl LogBackend {
816 #[must_use]
818 #[allow(clippy::needless_pass_by_value)]
819 pub fn for_spec(
820 spec: &StoreSpec,
821 data_dir: &std::path::Path,
822 #[cfg_attr(
823 not(any(feature = "postgres", feature = "turso", feature = "s3")),
824 allow(unused_variables)
825 )]
826 store: Arc<NodeStore>,
827 ) -> Self {
828 match spec {
829 StoreSpec::Memory => Self::Mem(MemLogRegistry::new()),
830 StoreSpec::Fs => Self::Fs(data_dir.join("logs")),
831 #[cfg(feature = "postgres")]
832 StoreSpec::Postgres { .. } => Self::Native(Arc::new(NativeRootLogStore::new(store))),
833 #[cfg(feature = "turso")]
834 StoreSpec::Turso { .. } => Self::Native(Arc::new(NativeRootLogStore::new(store))),
835 #[cfg(feature = "s3")]
836 StoreSpec::S3 { .. } => Self::Native(Arc::new(NativeRootLogStore::new(store))),
837 }
838 }
839
840 #[allow(clippy::needless_pass_by_value)]
846 pub(crate) fn for_discovered(
847 spec: &DiscoveredStoreSpec,
848 #[cfg_attr(
849 not(any(feature = "postgres", feature = "turso", feature = "s3")),
850 allow(unused_variables)
851 )]
852 store: Arc<DiscoveredStore>,
853 ) -> Result<Self, StorageConnectionError> {
854 #[allow(unreachable_patterns)]
857 match spec {
858 DiscoveredStoreSpec::Filesystem { root } => Ok(Self::Fs(
859 root.parent()
860 .map_or_else(|| PathBuf::from("logs"), |data_dir| data_dir.join("logs")),
861 )),
862 #[cfg(feature = "postgres")]
863 DiscoveredStoreSpec::Postgres { .. } => Ok(Self::Native(Arc::new(
864 NativeRootLogStore::new_discovered(store),
865 ))),
866 #[cfg(feature = "turso")]
867 DiscoveredStoreSpec::Turso { .. } => Ok(Self::Native(Arc::new(
868 NativeRootLogStore::new_discovered(store),
869 ))),
870 #[cfg(feature = "s3")]
871 DiscoveredStoreSpec::S3 { .. } => Ok(Self::Native(Arc::new(
872 NativeRootLogStore::new_discovered(store),
873 ))),
874 _ => Err(StorageConnectionError::Unsupported(
875 "this corium-transactor build lacks the advertised storage backend".into(),
876 )),
877 }
878 }
879
880 pub async fn open(
885 &self,
886 name: &str,
887 write_version: u64,
888 cipher: Option<Arc<LogCipher>>,
889 ) -> Result<Arc<dyn TransactionLog>, LogError> {
890 match self {
891 Self::Fs(dir) => {
892 let dir = dir.clone();
893 let name = name.to_owned();
894 let log = tokio::task::spawn_blocking(move || match cipher {
895 Some(cipher) => VersionedLog::open_sealed(dir, &name, write_version, cipher),
896 None => VersionedLog::open(dir, &name, write_version),
897 })
898 .await
899 .map_err(|error| LogError::Native(format!("log task failed: {error}")))??;
900 Ok(Arc::new(BlockingTransactionLog(Arc::new(log))))
901 }
902 Self::Mem(registry) => Ok(Arc::new(registry.open(name, write_version))),
906 #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
907 Self::Native(storage) => Ok(Arc::new(match cipher {
908 Some(cipher) => {
909 NativeVersionedLog::open_sealed(
910 Arc::clone(storage),
911 name,
912 write_version,
913 cipher,
914 )
915 .await?
916 }
917 None => NativeVersionedLog::open(Arc::clone(storage), name, write_version).await?,
918 })),
919 }
920 }
921
922 pub async fn open_read_only(
931 &self,
932 name: &str,
933 cipher: Option<Arc<LogCipher>>,
934 ) -> Result<Arc<dyn TransactionLog>, LogError> {
935 match self {
936 Self::Fs(dir) => {
937 let dir = dir.clone();
938 let name = name.to_owned();
939 let log = tokio::task::spawn_blocking(move || match cipher {
940 Some(cipher) => VersionedLog::open_read_only_sealed(dir, &name, cipher),
941 None => VersionedLog::open_read_only(dir, &name),
942 })
943 .await
944 .map_err(|error| LogError::Native(format!("log task failed: {error}")))??;
945 Ok(Arc::new(ReadOnlyTransactionLog(Arc::new(
946 BlockingTransactionLog(Arc::new(log)),
947 ))))
948 }
949 Self::Mem(registry) => Ok(Arc::new(ReadOnlyTransactionLog(Arc::new(
950 registry.open(name, 0),
951 )))),
952 #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
953 Self::Native(storage) => Ok(Arc::new(ReadOnlyTransactionLog(Arc::new(match cipher {
954 Some(cipher) => {
955 NativeVersionedLog::open_read_only_sealed(Arc::clone(storage), name, cipher)
956 }
957 None => NativeVersionedLog::open_read_only(Arc::clone(storage), name),
958 })))),
959 }
960 }
961
962 #[must_use]
964 pub async fn exists(&self, name: &str) -> bool {
965 match self {
966 Self::Fs(dir) => {
967 let dir = dir.clone();
968 let name = name.to_owned();
969 tokio::task::spawn_blocking(move || VersionedLog::exists(dir, &name))
970 .await
971 .unwrap_or(false)
972 }
973 Self::Mem(registry) => registry.exists(name),
974 #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
975 Self::Native(storage) => {
976 storage
977 .list_records(name)
978 .await
979 .is_ok_and(|records| !records.is_empty())
980 || storage
981 .list_legacy_chunks(name)
982 .await
983 .is_ok_and(|chunks| !chunks.is_empty())
984 }
985 }
986 }
987
988 pub async fn delete_all(&self, name: &str) -> Result<(), LogError> {
993 match self {
994 Self::Fs(dir) => {
995 let dir = dir.clone();
996 let name = name.to_owned();
997 tokio::task::spawn_blocking(move || VersionedLog::delete_all(dir, &name))
998 .await
999 .map_err(|error| LogError::Native(format!("log task failed: {error}")))?
1000 }
1001 Self::Mem(registry) => {
1002 registry.delete_all(name);
1003 Ok(())
1004 }
1005 #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
1006 Self::Native(storage) => storage.delete_all(name).await,
1007 }
1008 }
1009}
1010
1011#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
1012struct NativeRootLogStore {
1013 store: NativeLogRootStore,
1014}
1015
1016#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
1017enum NativeLogRootStore {
1018 ReadWrite(Arc<dyn RootStore>),
1019 Discovered(Arc<DiscoveredStore>),
1020}
1021
1022#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
1023impl NativeRootLogStore {
1024 fn new<S>(store: Arc<S>) -> Self
1025 where
1026 S: RootStore + 'static,
1027 {
1028 Self {
1029 store: NativeLogRootStore::ReadWrite(store),
1030 }
1031 }
1032
1033 fn new_discovered(store: Arc<DiscoveredStore>) -> Self {
1034 Self {
1035 store: NativeLogRootStore::Discovered(store),
1036 }
1037 }
1038
1039 async fn get_root(&self, name: &str) -> Result<Option<Vec<u8>>, LogError> {
1040 match &self.store {
1041 NativeLogRootStore::ReadWrite(store) => store.get_root(name).await,
1042 NativeLogRootStore::Discovered(store) => store.get_root(name).await,
1043 }
1044 .map_err(|error| LogError::Native(error.to_string()))
1045 }
1046
1047 async fn list_roots(&self, prefix: &str) -> Result<Vec<String>, LogError> {
1048 match &self.store {
1049 NativeLogRootStore::ReadWrite(store) => store.list_roots(prefix).await,
1050 NativeLogRootStore::Discovered(store) => store.list_roots(prefix).await,
1051 }
1052 .map_err(|error| LogError::Native(error.to_string()))
1053 }
1054}
1055
1056#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
1059enum LogKey {
1060 Record(u64, u64),
1062 Legacy(u64, u64),
1064}
1065
1066#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
1067impl NativeRootLogStore {
1068 fn record_key(name: &str, version: u64, t: u64) -> String {
1071 format!("log:{name}:v{version:020}:r{t:020}")
1072 }
1073
1074 fn legacy_key(name: &str, version: u64, chunk: u64) -> String {
1078 if chunk == 0 {
1079 format!("log:{name}:v{version:020}")
1080 } else {
1081 format!("log:{name}:v{version:020}:c{chunk:020}")
1082 }
1083 }
1084
1085 fn prefix(name: &str) -> String {
1086 format!("log:{name}:v")
1087 }
1088
1089 fn parse_key(prefix: &str, key: &str) -> Option<LogKey> {
1093 let rest = key.strip_prefix(prefix)?;
1094 if let Some((version, t)) = rest.split_once(":r") {
1095 Some(LogKey::Record(version.parse().ok()?, t.parse().ok()?))
1096 } else if let Some((version, chunk)) = rest.split_once(":c") {
1097 Some(LogKey::Legacy(version.parse().ok()?, chunk.parse().ok()?))
1098 } else {
1099 Some(LogKey::Legacy(rest.parse().ok()?, 0))
1100 }
1101 }
1102}
1103
1104#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
1105#[async_trait]
1106impl NativeLogStorage for NativeRootLogStore {
1107 async fn put_batch(
1108 &self,
1109 name: &str,
1110 version: u64,
1111 records: &[(u64, Vec<u8>)],
1112 ) -> Result<bool, LogError> {
1113 let Some((last_t, _)) = records.last() else {
1114 return Ok(true);
1115 };
1116 let mut bytes = Vec::new();
1124 for (_, framed) in records {
1125 bytes.extend_from_slice(framed);
1126 }
1127 let NativeLogRootStore::ReadWrite(store) = &self.store else {
1128 return Err(LogError::Native(
1129 "transaction log storage is read-only".into(),
1130 ));
1131 };
1132 match store
1133 .cas_root(&Self::record_key(name, version, *last_t), None, &bytes)
1134 .await
1135 {
1136 Ok(()) => Ok(true),
1137 Err(StoreError::CasFailed { .. }) => Ok(false),
1138 Err(error) => Err(LogError::Native(error.to_string())),
1139 }
1140 }
1141
1142 async fn read_record(
1143 &self,
1144 name: &str,
1145 version: u64,
1146 t: u64,
1147 ) -> Result<Option<Vec<u8>>, LogError> {
1148 self.get_root(&Self::record_key(name, version, t)).await
1149 }
1150
1151 async fn list_records(&self, name: &str) -> Result<Vec<(u64, u64)>, LogError> {
1152 let prefix = Self::prefix(name);
1153 let names = self.list_roots(&prefix).await?;
1154 names
1155 .into_iter()
1156 .filter_map(|key| match Self::parse_key(&prefix, &key) {
1157 Some(LogKey::Record(version, t)) => Some(Ok((version, t))),
1158 Some(LogKey::Legacy(..)) => None,
1159 None => Some(Err(LogError::Corrupt)),
1160 })
1161 .collect()
1162 }
1163
1164 async fn read_legacy_chunk(
1165 &self,
1166 name: &str,
1167 version: u64,
1168 chunk: u64,
1169 ) -> Result<Option<Vec<u8>>, LogError> {
1170 self.get_root(&Self::legacy_key(name, version, chunk)).await
1171 }
1172
1173 async fn list_legacy_chunks(&self, name: &str) -> Result<Vec<(u64, u64)>, LogError> {
1174 let prefix = Self::prefix(name);
1175 let names = self.list_roots(&prefix).await?;
1176 names
1177 .into_iter()
1178 .filter_map(|key| match Self::parse_key(&prefix, &key) {
1179 Some(LogKey::Legacy(version, chunk)) => Some(Ok((version, chunk))),
1180 Some(LogKey::Record(..)) => None,
1181 None => Some(Err(LogError::Corrupt)),
1182 })
1183 .collect()
1184 }
1185
1186 async fn delete_all(&self, name: &str) -> Result<(), LogError> {
1187 let prefix = Self::prefix(name);
1188 let NativeLogRootStore::ReadWrite(store) = &self.store else {
1189 return Err(LogError::Native(
1190 "transaction log storage is read-only".into(),
1191 ));
1192 };
1193 let names = store
1194 .list_roots(&prefix)
1195 .await
1196 .map_err(|error| LogError::Native(error.to_string()))?;
1197 for key in names {
1198 store
1199 .delete_root(&key)
1200 .await
1201 .map_err(|error| LogError::Native(error.to_string()))?;
1202 }
1203 Ok(())
1204 }
1205}
1206
1207#[cfg(all(test, any(feature = "postgres", feature = "turso", feature = "s3")))]
1208mod tests {
1209 use super::*;
1210 use corium_core::{Datom, EntityId, Value};
1211
1212 #[cfg(feature = "postgres")]
1213 #[tokio::test]
1214 async fn postgres_storage_info_uses_only_the_read_only_url() {
1215 let primary = "postgresql://writer:primary-secret@db/corium";
1216 let read_only = "postgresql://reader:read-secret@db/corium";
1217 let spec = StoreSpec::Postgres {
1218 connection_string: primary.into(),
1219 };
1220 let missing = spec
1221 .connection_info(std::path::Path::new("."), &StorageInfoConfig::default())
1222 .await
1223 .expect_err("primary URL must not be advertised");
1224 assert!(missing.contains("read-only"));
1225
1226 let info = spec
1227 .connection_info(
1228 std::path::Path::new("."),
1229 &StorageInfoConfig {
1230 postgres_connection_string: Some(read_only.into()),
1231 #[cfg(feature = "s3")]
1232 s3: None,
1233 },
1234 )
1235 .await
1236 .expect("read-only info");
1237 let Some(corium_protocol::pb::storage_connection::Backend::Postgres(postgres)) =
1238 info.backend
1239 else {
1240 panic!("PostgreSQL storage info");
1241 };
1242 assert_eq!(postgres.connection_string, read_only);
1243 assert!(!postgres.connection_string.contains("primary-secret"));
1244 }
1245
1246 #[cfg(feature = "s3")]
1247 #[tokio::test]
1248 async fn s3_storage_info_carries_static_read_only_credentials() {
1249 let missing =
1250 DiscoveredStoreSpec::from_connection(corium_protocol::pb::StorageConnection {
1251 backend: Some(corium_protocol::pb::storage_connection::Backend::S3(
1252 corium_protocol::pb::S3Storage {
1253 bucket: "bucket".into(),
1254 prefix: String::new(),
1255 ..Default::default()
1256 },
1257 )),
1258 })
1259 .expect_err("S3 clients must not fall back to ambient credentials");
1260 assert!(missing.to_string().contains("read-only access key"));
1261
1262 let spec = StoreSpec::S3 {
1263 bucket: "bucket".into(),
1264 prefix: "tenant/".into(),
1265 client: S3ClientConfig {
1266 access_key_id: Some("PRIMARY".into()),
1267 secret_access_key: Some("primary-secret".into()),
1268 ..S3ClientConfig::default()
1269 },
1270 };
1271 let info = spec
1272 .connection_info(
1273 std::path::Path::new("."),
1274 &StorageInfoConfig {
1275 postgres_connection_string: None,
1276 s3: Some(S3ReadOnlyConfig::new(
1277 Some("us-west-2".into()),
1278 Some("https://objects.example".into()),
1279 S3ReadOnlyCredentials::Static {
1280 access_key_id: "READONLY".into(),
1281 secret_access_key: "read-secret".into(),
1282 session_token: Some("session".into()),
1283 },
1284 )),
1285 },
1286 )
1287 .await
1288 .expect("read-only info");
1289 let Some(corium_protocol::pb::storage_connection::Backend::S3(mut s3)) = info.backend
1290 else {
1291 panic!("S3 storage info");
1292 };
1293 assert_eq!(s3.bucket, "bucket");
1294 assert_eq!(s3.prefix, "tenant/");
1295 assert_eq!(s3.access_key_id, "READONLY");
1296 assert_eq!(s3.secret_access_key, "read-secret");
1297 assert_eq!(s3.session_token, "session");
1298 assert_eq!(s3.region, "us-west-2");
1299 assert_eq!(s3.endpoint_url, "https://objects.example");
1300 assert_eq!(s3.expires_unix_seconds, 0);
1301 assert_ne!(s3.access_key_id, "PRIMARY");
1302
1303 s3.expires_unix_seconds = 1_900_000_000;
1304 let DiscoveredStoreSpec::S3 { client, .. } =
1305 DiscoveredStoreSpec::from_connection(corium_protocol::pb::StorageConnection {
1306 backend: Some(corium_protocol::pb::storage_connection::Backend::S3(s3)),
1307 })
1308 .expect("parse S3 storage info")
1309 else {
1310 panic!("S3 store spec");
1311 };
1312 assert_eq!(
1313 client
1314 .expires_after
1315 .expect("temporary credential expiration")
1316 .duration_since(UNIX_EPOCH)
1317 .expect("timestamp after epoch")
1318 .as_secs(),
1319 1_900_000_000
1320 );
1321 }
1322
1323 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1324 async fn native_root_log_writes_one_object_per_record_on_the_runtime() {
1325 let run = async {
1326 let dir = tempfile::tempdir().expect("tempdir");
1327 let store = Arc::new(
1328 NodeStore::open(&StoreSpec::Memory, dir.path())
1329 .await
1330 .expect("memory store"),
1331 );
1332 let storage = Arc::new(NativeRootLogStore::new(store));
1333 let log = NativeVersionedLog::open(Arc::clone(&storage), "db", 1)
1334 .await
1335 .expect("open native log");
1336 for t in 1..=3 {
1339 log.append_async(&TxRecord {
1340 t,
1341 tx_instant: i64::try_from(t).expect("small t"),
1342 datoms: vec![Datom {
1343 e: EntityId::from_raw(t),
1344 a: EntityId::from_raw(1),
1345 v: Value::Bytes(vec![0; 300 * 1024].into()),
1346 tx: EntityId::from_raw(t),
1347 added: true,
1348 }],
1349 })
1350 .await
1351 .expect("append");
1352 }
1353 let records = storage.list_records("db").await.expect("list records");
1354 assert_eq!(records.len(), 3);
1355 assert!(
1356 storage
1357 .list_legacy_chunks("db")
1358 .await
1359 .expect("list legacy")
1360 .is_empty()
1361 );
1362 assert_eq!(log.replay_async().await.expect("replay").len(), 3);
1363 };
1364 tokio::time::timeout(std::time::Duration::from_secs(5), run)
1365 .await
1366 .expect("native log operation stalled on its runtime");
1367 }
1368}