Skip to main content

boatramp_node/
backends.rs

1//! Backend-selection enums for a node's blob + KV stores (node-library N2b).
2//!
3//! These are the domain types the store-construction assembly dispatches on. They
4//! live here (not the binary) so the assembly can move into the library; the
5//! `clap::ValueEnum` derive is behind the optional `clap` feature so the CLI
6//! binary uses them directly in its args, while a non-CLI embedder never pulls
7//! clap. (`build_kv`/`build_blobs` join this module as they migrate off the
8//! binary's `ServeArgs`.)
9
10use std::path::Path;
11use std::sync::Arc;
12
13use boatramp_core::kv::{KvOpenPolicy, KvStore, MemoryKv};
14
15use crate::error::Result;
16
17/// Blob (file-content) backend.
18///
19/// `Deserialize` (lowercase: `fs`/`s3`/`gcs`/`azure`) so `[serve].blobs` in `boatramp.cfg` can
20/// select the backend — the config-level analog of the `--blobs` flag, which `boatramp blob
21/// migrate` reads to build a source/destination backend from a config file alone.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
23#[serde(rename_all = "lowercase")]
24#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
25pub enum BlobBackend {
26    /// Local filesystem (`<data-dir>/blobs`).
27    Fs,
28    /// S3-compatible object store (requires `--features s3`).
29    S3,
30    /// Google Cloud Storage (requires `--features gcs`).
31    Gcs,
32    /// Azure Blob Storage (requires `--features azure`).
33    Azure,
34}
35
36/// Metadata (manifest + pointer) backend.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
39pub enum KvBackend {
40    /// Transactional LSM over object storage; durable local default
41    /// (`<data-dir>/kv-slate`). Requires `--features slatedb` (on by default).
42    Slatedb,
43    /// In-memory (ephemeral; lost on restart).
44    Memory,
45    /// Cloudflare KV over REST (requires `--features cloudflare-kv`).
46    Cloudflare,
47    /// SQL-backed KV. SQLite/libsql-LOCAL single-writer today (a simple, robust single-node/dev
48    /// store with no SlateDB torn-manifest class); Postgres/MySQL (multi-writer) land later.
49    /// Connection via `[serve.kv.sql]` / `BOATRAMP_KV_SQL_*`. Requires `--features sql` (implied by
50    /// `handlers`, so on in the default build).
51    Sql,
52}
53
54/// Flush interval for the control-plane SlateDB store: tiny, so a control-plane
55/// write is durable almost immediately (correctness over throughput).
56pub const CONTROL_PLANE_FLUSH: std::time::Duration = std::time::Duration::from_millis(5);
57
58/// Where the SlateDB control-plane store lives when it runs on an S3-compatible
59/// object store (Cloudflare R2) instead of local disk — the durable, remote-state
60/// deployment. Credentials come from the ambient AWS environment
61/// (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`), matching the S3 blob backend.
62#[derive(Debug, Clone)]
63pub struct SlateKvS3 {
64    /// The bucket the store lives in (shared with S3 blobs, under `prefix`).
65    pub bucket: String,
66    /// Custom endpoint (R2: `https://<account>.r2.cloudflarestorage.com`).
67    pub endpoint: Option<String>,
68    /// Region (R2 uses `auto`).
69    pub region: Option<String>,
70    /// Use path-style addressing (R2 accepts it).
71    pub path_style: bool,
72    /// Key prefix within the bucket (keeps the LSM files apart from the blobs).
73    pub prefix: String,
74}
75
76/// Build the metadata KV store for the selected [`KvBackend`]. When `slate_s3` is
77/// set (and the backend is SlateDB), the store runs on R2/S3 (durable across a
78/// scale-to-zero container stop) rather than the local `data_dir`.
79///
80/// `policy` (v0.9.0 KV-recovery, C3/C7/C8/C11) is the cold-open recovery policy for the SlateDB
81/// backend (a no-op for Memory/Cloudflare). This is the SINGLE-NODE control-plane open site, so its
82/// default is [`KvOpenPolicy::SelfHeal`]; the caller passes [`KvOpenPolicy::Strict`] for
83/// `--strict-kv`. (The cluster node-local Raft store opens elsewhere and stays strict — C8.)
84///
85/// `sql` is the `[serve.kv.sql]` connection config (env-resolved by the caller), consulted only for
86/// [`KvBackend::Sql`] and ignored otherwise.
87pub async fn build_kv(
88    kv: KvBackend,
89    data_dir: &Path,
90    slate_s3: Option<&SlateKvS3>,
91    policy: KvOpenPolicy,
92    sql: Option<&crate::config::SqlKvConfig>,
93) -> Result<Arc<dyn KvStore>> {
94    match kv {
95        KvBackend::Slatedb => build_slatedb_kv(data_dir, slate_s3, policy).await,
96        KvBackend::Memory => Ok(Arc::new(MemoryKv::new())),
97        KvBackend::Cloudflare => build_cloudflare_kv(),
98        KvBackend::Sql => build_sql_kv(sql).await,
99    }
100}
101
102/// Build the SQL control-plane KV from its `[serve.kv.sql]` config. Wires the embedded single-node
103/// **SQLite / libsql-local** path (`kind = sqlite`, by on-disk `path`, single-writer), the
104/// **Postgres** primary (`kind = postgres`, multi-writer) and the **MySQL / MariaDB** primary
105/// (`kind = mysql`/`mariadb`, multi-writer) — both by `url_env`-named URL. A remote-sqld `libsql`
106/// `url_env` is a later workstream; an unknown kind is refused with a clear, actionable message
107/// rather than silently opened against the wrong backend.
108#[cfg(feature = "sql")]
109async fn build_sql_kv(sql: Option<&crate::config::SqlKvConfig>) -> Result<Arc<dyn KvStore>> {
110    use crate::error::Error;
111    let cfg = sql.ok_or_else(|| {
112        Error::SqlKvConfig(
113            "`--kv sql` needs a `[serve.kv.sql]` config block (or the `BOATRAMP_KV_SQL_*` env)"
114                .to_string(),
115        )
116    })?;
117    // `kind` uses the same alias set as the `databases:` block; empty defaults to sqlite.
118    match cfg.kind.trim().to_ascii_lowercase().as_str() {
119        "" | "sqlite" | "sqlite3" | "libsql" => {
120            let path = cfg
121                .path
122                .as_deref()
123                .filter(|p| !p.is_empty())
124                .ok_or_else(|| {
125                    Error::SqlKvConfig(
126                    "`[serve.kv.sql] kind = sqlite` needs `path` (an on-disk file) — set it or \
127                     `BOATRAMP_KV_SQL_PATH`"
128                        .to_string(),
129                )
130                })?;
131            Ok(Arc::new(
132                boatramp_storage::SqlKv::open_sqlite_local(path).await?,
133            ))
134        }
135        engine @ ("postgres" | "postgresql" | "pg") => build_pg_kv(cfg, engine).await,
136        engine @ ("mysql" | "mariadb") => build_mysql_kv(cfg, engine).await,
137        other => Err(Error::SqlKvConfig(format!(
138            "unknown `[serve.kv.sql] kind` {other:?}: expected sqlite | postgres | mysql"
139        ))),
140    }
141}
142
143/// Resolve the connection URL for a `postgres`/`mysql` KV from `cfg.url_env` — the NAME of the env
144/// var holding the URL (UX-C2: never a raw URL in config). Shared by the Postgres and MySQL
145/// constructors. `engine` names the engine in the error messages.
146#[cfg(all(feature = "sql", any(feature = "sql-postgres", feature = "sql-mysql")))]
147fn resolve_kv_url(cfg: &crate::config::SqlKvConfig, engine: &str) -> Result<String> {
148    use crate::error::Error;
149    let url_env = cfg
150        .url_env
151        .as_deref()
152        .map(str::trim)
153        .filter(|s| !s.is_empty())
154        .ok_or_else(|| {
155            Error::SqlKvConfig(format!(
156                "`[serve.kv.sql] kind = {engine}` needs `url_env` (the NAME of the env var holding \
157                 the connection URL — never a raw URL in config) — set it or `BOATRAMP_KV_SQL_URL_ENV`"
158            ))
159        })?;
160    std::env::var(url_env)
161        .ok()
162        .map(|v| v.trim().to_string())
163        .filter(|v| !v.is_empty())
164        .ok_or_else(|| {
165            Error::SqlKvConfig(format!(
166                "`[serve.kv.sql] url_env = {url_env:?}` names an unset or empty env var; set \
167                 {url_env} to the {engine} connection URL"
168            ))
169        })
170}
171
172/// Open the Postgres control-plane KV from `cfg`, resolving the connection URL from the env var
173/// `cfg.url_env` NAMES (UX-C2 — never a raw URL in config) and passing `cfg.pool_max` to the pool.
174/// Requires the `sql-postgres` engine; a build without it refuses with an actionable rebuild hint.
175#[cfg(all(feature = "sql", feature = "sql-postgres"))]
176async fn build_pg_kv(cfg: &crate::config::SqlKvConfig, engine: &str) -> Result<Arc<dyn KvStore>> {
177    let url = resolve_kv_url(cfg, engine)?;
178    Ok(Arc::new(
179        boatramp_storage::SqlKv::open_postgres(url, cfg.pool_max).await?,
180    ))
181}
182
183#[cfg(all(feature = "sql", not(feature = "sql-postgres")))]
184async fn build_pg_kv(_cfg: &crate::config::SqlKvConfig, engine: &str) -> Result<Arc<dyn KvStore>> {
185    Err(crate::error::Error::SqlKvConfig(format!(
186        "the `{engine}` SQL KV backend needs the Postgres engine — rebuild with `--features sql-postgres`"
187    )))
188}
189
190/// Open the MySQL / MariaDB control-plane KV from `cfg`, resolving the connection URL from the env
191/// var `cfg.url_env` NAMES (UX-C2) and passing `cfg.pool_max` to the pool. Requires the `sql-mysql`
192/// engine; a build without it refuses with an actionable rebuild hint.
193#[cfg(all(feature = "sql", feature = "sql-mysql"))]
194async fn build_mysql_kv(
195    cfg: &crate::config::SqlKvConfig,
196    engine: &str,
197) -> Result<Arc<dyn KvStore>> {
198    let url = resolve_kv_url(cfg, engine)?;
199    Ok(Arc::new(
200        boatramp_storage::SqlKv::open_mysql(url, cfg.pool_max).await?,
201    ))
202}
203
204#[cfg(all(feature = "sql", not(feature = "sql-mysql")))]
205async fn build_mysql_kv(
206    _cfg: &crate::config::SqlKvConfig,
207    engine: &str,
208) -> Result<Arc<dyn KvStore>> {
209    Err(crate::error::Error::SqlKvConfig(format!(
210        "the `{engine}` SQL KV backend needs the MySQL engine — rebuild with `--features sql-mysql`"
211    )))
212}
213
214#[cfg(not(feature = "sql"))]
215async fn build_sql_kv(_sql: Option<&crate::config::SqlKvConfig>) -> Result<Arc<dyn KvStore>> {
216    Err(crate::error::Error::NoSqlSupport)
217}
218
219#[cfg(feature = "slatedb")]
220async fn build_slatedb_kv(
221    data_dir: &Path,
222    slate_s3: Option<&SlateKvS3>,
223    policy: KvOpenPolicy,
224) -> Result<Arc<dyn KvStore>> {
225    match slate_s3 {
226        Some(s3) => Ok(Arc::new(
227            boatramp_storage::SlateKv::open_s3_with_flush_policy(
228                &boatramp_storage::S3StoreConfig {
229                    bucket: s3.bucket.clone(),
230                    endpoint: s3.endpoint.clone(),
231                    region: s3.region.clone(),
232                    path_style: s3.path_style,
233                },
234                &s3.prefix,
235                CONTROL_PLANE_FLUSH,
236                policy,
237            )
238            .await?,
239        )),
240        None => Ok(Arc::new(
241            boatramp_storage::SlateKv::open_local_with_flush_policy(
242                data_dir.join("kv-slate"),
243                CONTROL_PLANE_FLUSH,
244                policy,
245            )
246            .await?,
247        )),
248    }
249}
250
251#[cfg(not(feature = "slatedb"))]
252async fn build_slatedb_kv(
253    _data_dir: &Path,
254    _slate_s3: Option<&SlateKvS3>,
255    _policy: KvOpenPolicy,
256) -> Result<Arc<dyn KvStore>> {
257    Err(crate::error::Error::NoSlatedbSupport)
258}
259
260#[cfg(feature = "cloudflare-kv")]
261fn build_cloudflare_kv() -> Result<Arc<dyn KvStore>> {
262    Ok(Arc::new(boatramp_storage::CloudflareKv::from_env()?))
263}
264
265#[cfg(not(feature = "cloudflare-kv"))]
266fn build_cloudflare_kv() -> Result<Arc<dyn KvStore>> {
267    Err(crate::error::Error::NoCloudflareKvSupport)
268}