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