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::{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/// 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        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            // In-memory logs hold decoded records in this process's heap and
903            // never reach a durable medium, which is the only thing storage
904            // encryption protects. Sealing them would encrypt nothing.
905            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    /// Opens the named log for independent read-only replay.
923    ///
924    /// Native logs use a non-writing versioned-log handle; filesystem logs
925    /// use the explicit read-only opener so this path never creates or
926    /// truncates a source log.
927    ///
928    /// # Errors
929    /// Returns an error when a transaction log cannot be opened.
930    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    /// Reports whether any log exists for `name`.
963    #[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    /// Deletes every log for `name`.
989    ///
990    /// # Errors
991    /// Returns an error when a transaction log cannot be removed.
992    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/// A parsed log object key: either a per-transaction record or a legacy chunk
1057/// written by the pre-per-record layout.
1058#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
1059enum LogKey {
1060    /// Per-transaction record object `(version, t)`.
1061    Record(u64, u64),
1062    /// Legacy chunk object `(version, chunk)`.
1063    Legacy(u64, u64),
1064}
1065
1066#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
1067impl NativeRootLogStore {
1068    /// Object key for one per-transaction record: `log:<db>:v<version>:r<t>`,
1069    /// with both numbers zero-padded so a listing sorts by `(version, t)`.
1070    fn record_key(name: &str, version: u64, t: u64) -> String {
1071        format!("log:{name}:v{version:020}:r{t:020}")
1072    }
1073
1074    /// Object key for one legacy chunk. Chunk `0` keeps the historical
1075    /// unsuffixed key, so logs written by earlier releases read back without
1076    /// migration.
1077    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    /// Classifies a listed log key. A `:r` suffix marks a per-record object; a
1090    /// `:c` suffix or a bare version marks a legacy chunk (chunk `0` when
1091    /// bare). The version is pure digits, so it never contains either marker.
1092    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        // The whole batch is one immutable object keyed by its last `t`,
1117        // holding the batch's framed records concatenated — the same encoding
1118        // a multi-record chunk uses, so the reader decodes it unchanged. On
1119        // SQL backends this is one row insert (one fsync for the batch); on an
1120        // object store, one create-only `PUT`. A create-only root CAS
1121        // (expected `None`) makes it atomic and fenced; a lost race surfaces
1122        // as `CasFailed`, which the caller maps to `false`.
1123        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            // Large records too: each transaction is its own object, so there
1337            // is no chunk cap to cross.
1338            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}