Skip to main content

corium_transactor/
backend.rs

1//! Pluggable transactor storage backends.
2//!
3//! A transactor keeps two kinds of durable state: the content-addressed
4//! blob store plus fenced root pointers (the "storage service"), and the
5//! per-database transaction log. [`StoreSpec`] selects the storage service
6//! backend — in-memory, filesystem, `PostgreSQL`, Turso, or S3 — and [`NodeStore`]
7//! dispatches the [`BlobStore`]/[`RootStore`] operations to it. Native
8//! service backends keep transaction logs in the same storage system as blobs
9//! and roots; memory and filesystem retain their existing log stores.
10
11use 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};
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/// Selects the transactor's storage-service backend (blobs + roots).
36#[derive(Clone, Default)]
37pub enum StoreSpec {
38    /// In-memory blobs and roots; fully ephemeral and confined to one
39    /// process. The transaction log is in memory too, so the whole database
40    /// vanishes when the process exits — ideal for demos and tests.
41    Memory,
42    /// Blobs and roots under `{data_dir}/store`, log under `{data_dir}/logs`.
43    #[default]
44    Fs,
45    /// Blobs, roots, and transaction logs in `PostgreSQL`.
46    #[cfg(feature = "postgres")]
47    Postgres {
48        /// `PostgreSQL` URL or keyword/value connection string.
49        connection_string: String,
50    },
51    /// Blobs, roots, and transaction logs in a Turso (embeddable `SQLite`)
52    /// database at `path`. `path` is a local database file.
53    #[cfg(feature = "turso")]
54    Turso {
55        /// Filesystem path of the Turso database.
56        path: String,
57    },
58    /// Blobs, roots, and transaction logs in an S3 (or S3-compatible) bucket.
59    #[cfg(feature = "s3")]
60    S3 {
61        /// Target bucket name.
62        bucket: String,
63        /// Key prefix namespacing every object this store touches.
64        prefix: String,
65        /// Explicit primary-client overrides. Empty values use the standard
66        /// AWS configuration chain.
67        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/// Credentials advertised by `GetStorageInfo` for S3 read-only clients.
99#[cfg(feature = "s3")]
100#[derive(Clone)]
101pub enum S3ReadOnlyCredentials {
102    /// Operator-provisioned credentials whose IAM/bucket policy permits only
103    /// reads of the configured Corium prefix.
104    Static {
105        /// Access-key id.
106        access_key_id: String,
107        /// Secret access key.
108        secret_access_key: String,
109        /// Optional session token.
110        session_token: Option<String>,
111    },
112    /// Temporary credentials obtained from AWS STS for this request.
113    ///
114    /// Corium attaches a restrictive session policy allowing only
115    /// `GetObject` and prefix-scoped `ListBucket`, so the issued credential
116    /// cannot write even if the role itself has broader permissions.
117    AssumeRole {
118        /// IAM role to assume.
119        role_arn: String,
120        /// STS role-session name.
121        session_name: String,
122        /// Requested token lifetime in seconds.
123        duration_seconds: i32,
124        /// Optional trust-policy external id.
125        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/// S3 endpoint metadata and read-only credential source advertised to peers.
151#[cfg(feature = "s3")]
152#[derive(Clone)]
153pub struct S3ReadOnlyConfig {
154    /// Region clients should use.
155    pub region: Option<String>,
156    /// Optional S3-compatible endpoint.
157    pub endpoint_url: Option<String>,
158    /// Static or STS-generated credentials.
159    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/// Separately provisioned credentials returned by `GetStorageInfo`.
200///
201/// Local storage backends do not consult this value. Service backends fail
202/// storage discovery rather than falling back to the transactor's primary
203/// read/write credentials.
204#[derive(Clone, Default)]
205pub struct StorageInfoConfig {
206    /// Read-only `PostgreSQL` URL.
207    pub postgres_connection_string: Option<String>,
208    /// Read-only S3 access.
209    #[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        // `corium-store` features may be unified by another dependency
234        // without enabling the matching `corium-transactor` feature.
235        #[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    /// Describes how an administrative client can independently open this
281    /// node's storage service.
282    ///
283    /// Local paths are made absolute because the client need not share the
284    /// transactor's working directory. The memory backend is described too,
285    /// but cannot be opened by another process.
286    ///
287    /// # Errors
288    /// Returns an error when a local path cannot be represented on the wire.
289    #[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    /// Creates read-only S3 discovery configuration with a shared AWS client
363    /// and temporary-credential cache.
364    #[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
556/// The blob + root storage service a [`crate::node::TransactorNode`] runs
557/// over, chosen by [`StoreSpec`]. Dispatch is an enum rather than a trait
558/// object so every existing `impl BlobStore + RootStore` / `&dyn RootStore`
559/// call site keeps working unchanged.
560pub enum NodeStore {
561    /// In-memory backend.
562    Mem(MemoryStore),
563    /// Filesystem backend.
564    Fs(FsStore),
565    /// `PostgreSQL` backend.
566    #[cfg(feature = "postgres")]
567    Postgres(PostgresBlobStore),
568    /// Turso backend.
569    #[cfg(feature = "turso")]
570    Turso(TursoBlobStore),
571    /// S3 backend.
572    #[cfg(feature = "s3")]
573    S3(S3BlobStore),
574}
575
576impl NodeStore {
577    /// Opens the storage service for `spec`, relative to `data_dir` for the
578    /// filesystem backend.
579    ///
580    /// # Errors
581    /// Returns an error when the backing store cannot be opened.
582    // Only optional database-backed arms await; mem/fs are synchronous.
583    #[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
747/// Where a node's per-database transaction logs live.
748pub enum LogBackend {
749    /// Versioned log files under this directory.
750    Fs(PathBuf),
751    /// In-memory versioned logs shared across a process.
752    Mem(MemLogRegistry),
753    /// Versioned logs stored through the native root store.
754    #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
755    Native(Arc<dyn NativeLogStorage>),
756}
757
758/// Runs filesystem log operations on Tokio's blocking pool while exposing the
759/// same async interface used by native storage logs.
760struct 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
792/// Prevents a storage handle opened for replay from being used to append even
793/// when its concrete implementation also supports writes.
794struct 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    /// The log backend that pairs with `spec`.
817    #[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    /// Returns the read-only log backend paired with a discovered storage service.
841    ///
842    /// # Errors
843    /// Returns an error when this transactor build lacks the advertised
844    /// backend, even if Cargo feature unification enabled it in `corium-store`.
845    #[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        // `corium-store` features may be unified by another dependency
855        // without enabling the matching `corium-transactor` feature.
856        #[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    /// Opens the named log for writing under `write_version`.
881    ///
882    /// # Errors
883    /// Returns an error when a transaction log cannot be opened.
884    pub async fn open(
885        &self,
886        name: &str,
887        write_version: u64,
888    ) -> Result<Arc<dyn TransactionLog>, LogError> {
889        match self {
890            Self::Fs(dir) => {
891                let dir = dir.clone();
892                let name = name.to_owned();
893                let log = tokio::task::spawn_blocking(move || {
894                    VersionedLog::open(dir, &name, write_version)
895                })
896                .await
897                .map_err(|error| LogError::Native(format!("log task failed: {error}")))??;
898                Ok(Arc::new(BlockingTransactionLog(Arc::new(log))))
899            }
900            Self::Mem(registry) => Ok(Arc::new(registry.open(name, write_version))),
901            #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
902            Self::Native(storage) => Ok(Arc::new(
903                NativeVersionedLog::open(Arc::clone(storage), name, write_version).await?,
904            )),
905        }
906    }
907
908    /// Opens the named log for independent read-only replay.
909    ///
910    /// Native logs use a non-writing versioned-log handle; filesystem logs
911    /// use the explicit read-only opener so this path never creates or
912    /// truncates a source log.
913    ///
914    /// # Errors
915    /// Returns an error when a transaction log cannot be opened.
916    pub async fn open_read_only(&self, name: &str) -> Result<Arc<dyn TransactionLog>, LogError> {
917        match self {
918            Self::Fs(dir) => {
919                let dir = dir.clone();
920                let name = name.to_owned();
921                let log =
922                    tokio::task::spawn_blocking(move || VersionedLog::open_read_only(dir, &name))
923                        .await
924                        .map_err(|error| LogError::Native(format!("log task failed: {error}")))??;
925                Ok(Arc::new(ReadOnlyTransactionLog(Arc::new(
926                    BlockingTransactionLog(Arc::new(log)),
927                ))))
928            }
929            Self::Mem(registry) => Ok(Arc::new(ReadOnlyTransactionLog(Arc::new(
930                registry.open(name, 0),
931            )))),
932            #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
933            Self::Native(storage) => Ok(Arc::new(ReadOnlyTransactionLog(Arc::new(
934                NativeVersionedLog::open_read_only(Arc::clone(storage), name),
935            )))),
936        }
937    }
938
939    /// Reports whether any log exists for `name`.
940    #[must_use]
941    pub async fn exists(&self, name: &str) -> bool {
942        match self {
943            Self::Fs(dir) => {
944                let dir = dir.clone();
945                let name = name.to_owned();
946                tokio::task::spawn_blocking(move || VersionedLog::exists(dir, &name))
947                    .await
948                    .unwrap_or(false)
949            }
950            Self::Mem(registry) => registry.exists(name),
951            #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
952            Self::Native(storage) => {
953                storage
954                    .list_records(name)
955                    .await
956                    .is_ok_and(|records| !records.is_empty())
957                    || storage
958                        .list_legacy_chunks(name)
959                        .await
960                        .is_ok_and(|chunks| !chunks.is_empty())
961            }
962        }
963    }
964
965    /// Deletes every log for `name`.
966    ///
967    /// # Errors
968    /// Returns an error when a transaction log cannot be removed.
969    pub async fn delete_all(&self, name: &str) -> Result<(), LogError> {
970        match self {
971            Self::Fs(dir) => {
972                let dir = dir.clone();
973                let name = name.to_owned();
974                tokio::task::spawn_blocking(move || VersionedLog::delete_all(dir, &name))
975                    .await
976                    .map_err(|error| LogError::Native(format!("log task failed: {error}")))?
977            }
978            Self::Mem(registry) => {
979                registry.delete_all(name);
980                Ok(())
981            }
982            #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
983            Self::Native(storage) => storage.delete_all(name).await,
984        }
985    }
986}
987
988#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
989struct NativeRootLogStore {
990    store: NativeLogRootStore,
991}
992
993#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
994enum NativeLogRootStore {
995    ReadWrite(Arc<dyn RootStore>),
996    Discovered(Arc<DiscoveredStore>),
997}
998
999#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
1000impl NativeRootLogStore {
1001    fn new<S>(store: Arc<S>) -> Self
1002    where
1003        S: RootStore + 'static,
1004    {
1005        Self {
1006            store: NativeLogRootStore::ReadWrite(store),
1007        }
1008    }
1009
1010    fn new_discovered(store: Arc<DiscoveredStore>) -> Self {
1011        Self {
1012            store: NativeLogRootStore::Discovered(store),
1013        }
1014    }
1015
1016    async fn get_root(&self, name: &str) -> Result<Option<Vec<u8>>, LogError> {
1017        match &self.store {
1018            NativeLogRootStore::ReadWrite(store) => store.get_root(name).await,
1019            NativeLogRootStore::Discovered(store) => store.get_root(name).await,
1020        }
1021        .map_err(|error| LogError::Native(error.to_string()))
1022    }
1023
1024    async fn list_roots(&self, prefix: &str) -> Result<Vec<String>, LogError> {
1025        match &self.store {
1026            NativeLogRootStore::ReadWrite(store) => store.list_roots(prefix).await,
1027            NativeLogRootStore::Discovered(store) => store.list_roots(prefix).await,
1028        }
1029        .map_err(|error| LogError::Native(error.to_string()))
1030    }
1031}
1032
1033/// A parsed log object key: either a per-transaction record or a legacy chunk
1034/// written by the pre-per-record layout.
1035#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
1036enum LogKey {
1037    /// Per-transaction record object `(version, t)`.
1038    Record(u64, u64),
1039    /// Legacy chunk object `(version, chunk)`.
1040    Legacy(u64, u64),
1041}
1042
1043#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
1044impl NativeRootLogStore {
1045    /// Object key for one per-transaction record: `log:<db>:v<version>:r<t>`,
1046    /// with both numbers zero-padded so a listing sorts by `(version, t)`.
1047    fn record_key(name: &str, version: u64, t: u64) -> String {
1048        format!("log:{name}:v{version:020}:r{t:020}")
1049    }
1050
1051    /// Object key for one legacy chunk. Chunk `0` keeps the historical
1052    /// unsuffixed key, so logs written by earlier releases read back without
1053    /// migration.
1054    fn legacy_key(name: &str, version: u64, chunk: u64) -> String {
1055        if chunk == 0 {
1056            format!("log:{name}:v{version:020}")
1057        } else {
1058            format!("log:{name}:v{version:020}:c{chunk:020}")
1059        }
1060    }
1061
1062    fn prefix(name: &str) -> String {
1063        format!("log:{name}:v")
1064    }
1065
1066    /// Classifies a listed log key. A `:r` suffix marks a per-record object; a
1067    /// `:c` suffix or a bare version marks a legacy chunk (chunk `0` when
1068    /// bare). The version is pure digits, so it never contains either marker.
1069    fn parse_key(prefix: &str, key: &str) -> Option<LogKey> {
1070        let rest = key.strip_prefix(prefix)?;
1071        if let Some((version, t)) = rest.split_once(":r") {
1072            Some(LogKey::Record(version.parse().ok()?, t.parse().ok()?))
1073        } else if let Some((version, chunk)) = rest.split_once(":c") {
1074            Some(LogKey::Legacy(version.parse().ok()?, chunk.parse().ok()?))
1075        } else {
1076            Some(LogKey::Legacy(rest.parse().ok()?, 0))
1077        }
1078    }
1079}
1080
1081#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
1082#[async_trait]
1083impl NativeLogStorage for NativeRootLogStore {
1084    async fn put_batch(
1085        &self,
1086        name: &str,
1087        version: u64,
1088        records: &[(u64, Vec<u8>)],
1089    ) -> Result<bool, LogError> {
1090        let Some((last_t, _)) = records.last() else {
1091            return Ok(true);
1092        };
1093        // The whole batch is one immutable object keyed by its last `t`,
1094        // holding the batch's framed records concatenated — the same encoding
1095        // a multi-record chunk uses, so the reader decodes it unchanged. On
1096        // SQL backends this is one row insert (one fsync for the batch); on an
1097        // object store, one create-only `PUT`. A create-only root CAS
1098        // (expected `None`) makes it atomic and fenced; a lost race surfaces
1099        // as `CasFailed`, which the caller maps to `false`.
1100        let mut bytes = Vec::new();
1101        for (_, framed) in records {
1102            bytes.extend_from_slice(framed);
1103        }
1104        let NativeLogRootStore::ReadWrite(store) = &self.store else {
1105            return Err(LogError::Native(
1106                "transaction log storage is read-only".into(),
1107            ));
1108        };
1109        match store
1110            .cas_root(&Self::record_key(name, version, *last_t), None, &bytes)
1111            .await
1112        {
1113            Ok(()) => Ok(true),
1114            Err(StoreError::CasFailed { .. }) => Ok(false),
1115            Err(error) => Err(LogError::Native(error.to_string())),
1116        }
1117    }
1118
1119    async fn read_record(
1120        &self,
1121        name: &str,
1122        version: u64,
1123        t: u64,
1124    ) -> Result<Option<Vec<u8>>, LogError> {
1125        self.get_root(&Self::record_key(name, version, t)).await
1126    }
1127
1128    async fn list_records(&self, name: &str) -> Result<Vec<(u64, u64)>, LogError> {
1129        let prefix = Self::prefix(name);
1130        let names = self.list_roots(&prefix).await?;
1131        names
1132            .into_iter()
1133            .filter_map(|key| match Self::parse_key(&prefix, &key) {
1134                Some(LogKey::Record(version, t)) => Some(Ok((version, t))),
1135                Some(LogKey::Legacy(..)) => None,
1136                None => Some(Err(LogError::Corrupt)),
1137            })
1138            .collect()
1139    }
1140
1141    async fn read_legacy_chunk(
1142        &self,
1143        name: &str,
1144        version: u64,
1145        chunk: u64,
1146    ) -> Result<Option<Vec<u8>>, LogError> {
1147        self.get_root(&Self::legacy_key(name, version, chunk)).await
1148    }
1149
1150    async fn list_legacy_chunks(&self, name: &str) -> Result<Vec<(u64, u64)>, LogError> {
1151        let prefix = Self::prefix(name);
1152        let names = self.list_roots(&prefix).await?;
1153        names
1154            .into_iter()
1155            .filter_map(|key| match Self::parse_key(&prefix, &key) {
1156                Some(LogKey::Legacy(version, chunk)) => Some(Ok((version, chunk))),
1157                Some(LogKey::Record(..)) => None,
1158                None => Some(Err(LogError::Corrupt)),
1159            })
1160            .collect()
1161    }
1162
1163    async fn delete_all(&self, name: &str) -> Result<(), LogError> {
1164        let prefix = Self::prefix(name);
1165        let NativeLogRootStore::ReadWrite(store) = &self.store else {
1166            return Err(LogError::Native(
1167                "transaction log storage is read-only".into(),
1168            ));
1169        };
1170        let names = store
1171            .list_roots(&prefix)
1172            .await
1173            .map_err(|error| LogError::Native(error.to_string()))?;
1174        for key in names {
1175            store
1176                .delete_root(&key)
1177                .await
1178                .map_err(|error| LogError::Native(error.to_string()))?;
1179        }
1180        Ok(())
1181    }
1182}
1183
1184#[cfg(all(test, any(feature = "postgres", feature = "turso", feature = "s3")))]
1185mod tests {
1186    use super::*;
1187    use corium_core::{Datom, EntityId, Value};
1188
1189    #[cfg(feature = "postgres")]
1190    #[tokio::test]
1191    async fn postgres_storage_info_uses_only_the_read_only_url() {
1192        let primary = "postgresql://writer:primary-secret@db/corium";
1193        let read_only = "postgresql://reader:read-secret@db/corium";
1194        let spec = StoreSpec::Postgres {
1195            connection_string: primary.into(),
1196        };
1197        let missing = spec
1198            .connection_info(std::path::Path::new("."), &StorageInfoConfig::default())
1199            .await
1200            .expect_err("primary URL must not be advertised");
1201        assert!(missing.contains("read-only"));
1202
1203        let info = spec
1204            .connection_info(
1205                std::path::Path::new("."),
1206                &StorageInfoConfig {
1207                    postgres_connection_string: Some(read_only.into()),
1208                    #[cfg(feature = "s3")]
1209                    s3: None,
1210                },
1211            )
1212            .await
1213            .expect("read-only info");
1214        let Some(corium_protocol::pb::storage_connection::Backend::Postgres(postgres)) =
1215            info.backend
1216        else {
1217            panic!("PostgreSQL storage info");
1218        };
1219        assert_eq!(postgres.connection_string, read_only);
1220        assert!(!postgres.connection_string.contains("primary-secret"));
1221    }
1222
1223    #[cfg(feature = "s3")]
1224    #[tokio::test]
1225    async fn s3_storage_info_carries_static_read_only_credentials() {
1226        let missing =
1227            DiscoveredStoreSpec::from_connection(corium_protocol::pb::StorageConnection {
1228                backend: Some(corium_protocol::pb::storage_connection::Backend::S3(
1229                    corium_protocol::pb::S3Storage {
1230                        bucket: "bucket".into(),
1231                        prefix: String::new(),
1232                        ..Default::default()
1233                    },
1234                )),
1235            })
1236            .expect_err("S3 clients must not fall back to ambient credentials");
1237        assert!(missing.to_string().contains("read-only access key"));
1238
1239        let spec = StoreSpec::S3 {
1240            bucket: "bucket".into(),
1241            prefix: "tenant/".into(),
1242            client: S3ClientConfig {
1243                access_key_id: Some("PRIMARY".into()),
1244                secret_access_key: Some("primary-secret".into()),
1245                ..S3ClientConfig::default()
1246            },
1247        };
1248        let info = spec
1249            .connection_info(
1250                std::path::Path::new("."),
1251                &StorageInfoConfig {
1252                    postgres_connection_string: None,
1253                    s3: Some(S3ReadOnlyConfig::new(
1254                        Some("us-west-2".into()),
1255                        Some("https://objects.example".into()),
1256                        S3ReadOnlyCredentials::Static {
1257                            access_key_id: "READONLY".into(),
1258                            secret_access_key: "read-secret".into(),
1259                            session_token: Some("session".into()),
1260                        },
1261                    )),
1262                },
1263            )
1264            .await
1265            .expect("read-only info");
1266        let Some(corium_protocol::pb::storage_connection::Backend::S3(mut s3)) = info.backend
1267        else {
1268            panic!("S3 storage info");
1269        };
1270        assert_eq!(s3.bucket, "bucket");
1271        assert_eq!(s3.prefix, "tenant/");
1272        assert_eq!(s3.access_key_id, "READONLY");
1273        assert_eq!(s3.secret_access_key, "read-secret");
1274        assert_eq!(s3.session_token, "session");
1275        assert_eq!(s3.region, "us-west-2");
1276        assert_eq!(s3.endpoint_url, "https://objects.example");
1277        assert_eq!(s3.expires_unix_seconds, 0);
1278        assert_ne!(s3.access_key_id, "PRIMARY");
1279
1280        s3.expires_unix_seconds = 1_900_000_000;
1281        let DiscoveredStoreSpec::S3 { client, .. } =
1282            DiscoveredStoreSpec::from_connection(corium_protocol::pb::StorageConnection {
1283                backend: Some(corium_protocol::pb::storage_connection::Backend::S3(s3)),
1284            })
1285            .expect("parse S3 storage info")
1286        else {
1287            panic!("S3 store spec");
1288        };
1289        assert_eq!(
1290            client
1291                .expires_after
1292                .expect("temporary credential expiration")
1293                .duration_since(UNIX_EPOCH)
1294                .expect("timestamp after epoch")
1295                .as_secs(),
1296            1_900_000_000
1297        );
1298    }
1299
1300    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1301    async fn native_root_log_writes_one_object_per_record_on_the_runtime() {
1302        let run = async {
1303            let dir = tempfile::tempdir().expect("tempdir");
1304            let store = Arc::new(
1305                NodeStore::open(&StoreSpec::Memory, dir.path())
1306                    .await
1307                    .expect("memory store"),
1308            );
1309            let storage = Arc::new(NativeRootLogStore::new(store));
1310            let log = NativeVersionedLog::open(Arc::clone(&storage), "db", 1)
1311                .await
1312                .expect("open native log");
1313            // Large records too: each transaction is its own object, so there
1314            // is no chunk cap to cross.
1315            for t in 1..=3 {
1316                log.append_async(&TxRecord {
1317                    t,
1318                    tx_instant: i64::try_from(t).expect("small t"),
1319                    datoms: vec![Datom {
1320                        e: EntityId::from_raw(t),
1321                        a: EntityId::from_raw(1),
1322                        v: Value::Bytes(vec![0; 300 * 1024].into()),
1323                        tx: EntityId::from_raw(t),
1324                        added: true,
1325                    }],
1326                })
1327                .await
1328                .expect("append");
1329            }
1330            let records = storage.list_records("db").await.expect("list records");
1331            assert_eq!(records.len(), 3);
1332            assert!(
1333                storage
1334                    .list_legacy_chunks("db")
1335                    .await
1336                    .expect("list legacy")
1337                    .is_empty()
1338            );
1339            assert_eq!(log.replay_async().await.expect("replay").len(), 3);
1340        };
1341        tokio::time::timeout(std::time::Duration::from_secs(5), run)
1342            .await
1343            .expect("native log operation stalled on its runtime");
1344    }
1345}