Skip to main content

boatramp_node/
handlers.rs

1//! WebAssembly handler-runtime assembly (moved from the binary — node-library N2b).
2//!
3//! Builds `boatramp_server::HandlerRuntime` from `[handlers]` config: the wasmtime
4//! engine plus the libsql `sql` binding (single-node file per site, a cluster sqld
5//! namespace, or an external Postgres/MySQL). Handlers-gated; a lean node gets a
6//! disabled runtime. Lives here (not the backend-agnostic `boatramp-server`)
7//! because it drives the concrete `boatramp-storage` SQL backends.
8
9#[cfg(feature = "handlers")]
10use crate::error::Error;
11use crate::error::Result;
12use boatramp_core::deploy::DeployStore;
13use boatramp_core::envelope::KeyEnvelope;
14use boatramp_core::kv::KvStore;
15use std::path::Path;
16use std::sync::Arc;
17
18/// Default async-lane wall-clock ceiling: 15 minutes. Large enough for a
19/// genuinely long background job (an LLM generation, a batch transform) while
20/// staying bounded — work that needs longer belongs in a workflow, one bounded
21/// invocation per step. The lease that guards a crashed-node reclaim is sized
22/// from this, so a bounded value also bounds the orphan-recovery window.
23#[cfg(feature = "handlers")]
24const DEFAULT_ASYNC_TIMEOUT_MS: u64 = 15 * 60 * 1000;
25
26/// Default async-lane concurrency: a small, isolated pool. The point of the
27/// separate budget is that a burst of long background jobs cannot exhaust the
28/// (much larger) request pool live site traffic draws from.
29#[cfg(feature = "handlers")]
30const DEFAULT_ASYNC_CONCURRENCY: usize = 8;
31
32/// Default streaming-lane wall-clock: like the async lane, a long-lived streaming
33/// response (SSE, agent token streaming) can run for minutes.
34#[cfg(feature = "handlers")]
35const DEFAULT_STREAMING_TIMEOUT_MS: u64 = 15 * 60 * 1000;
36
37/// Default streaming-lane concurrency: larger than the async drain because
38/// concurrent connected SSE clients are the expected shape, but still an isolated
39/// budget so a burst can't touch the fast request pool.
40#[cfg(feature = "handlers")]
41const DEFAULT_STREAMING_CONCURRENCY: usize = 64;
42
43/// Build the WebAssembly handler runtime. With the `handlers` feature it wraps a
44/// wasmtime engine serving the kv/blob bindings from the server's own backends;
45/// otherwise it is an empty placeholder (handler routes fall through to static).
46#[cfg(feature = "handlers")]
47#[allow(clippy::too_many_arguments)]
48pub async fn build_handler_runtime(
49    kv: Arc<dyn KvStore>,
50    storage: Arc<dyn boatramp_core::Storage>,
51    data_dir: &Path,
52    handlers_cfg: Option<&crate::config::HandlersConfig>,
53    messaging_override: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
54    max_blob_bytes: u64,
55    max_component_bytes: u64,
56    // The deploy store (for a managed compute-backed `sql` database's endpoint
57    // resolution) and the `[secrets]` envelope (to seal a managed credential).
58    deploy: &DeployStore,
59    secrets_envelope: Option<Arc<dyn KeyEnvelope>>,
60) -> Result<boatramp_server::HandlerRuntime> {
61    // Two engine ceilings by lane. The **sync** ceiling bounds connection-bearing
62    // requests (site handlers, synchronous invokes) — kept tight so a slow
63    // handler can't pin a client, a proxy, and the shared request pool; default
64    // 10s. The **async** ceiling bounds the durable drain / workflow / trigger /
65    // messaging path — no client is connected and the work is retried +
66    // dead-lettered, so it can run far longer (default 15 min) on its own
67    // concurrency budget, which is how a legitimately long background job (e.g.
68    // an LLM generation) can declare and actually get minutes of runtime without
69    // ever starving live site traffic.
70    let defaults = boatramp_handlers::Limits::default();
71    let sync_limits = boatramp_handlers::Limits {
72        timeout_ms: handlers_cfg
73            .and_then(|h| h.sync_max_timeout_ms)
74            .unwrap_or(defaults.timeout_ms),
75        ..defaults
76    };
77    let async_limits = boatramp_handlers::Limits {
78        timeout_ms: handlers_cfg
79            .and_then(|h| h.async_max_timeout_ms)
80            .unwrap_or(DEFAULT_ASYNC_TIMEOUT_MS),
81        max_concurrency: handlers_cfg
82            .and_then(|h| h.async_max_concurrency)
83            .unwrap_or(DEFAULT_ASYNC_CONCURRENCY),
84        fuel: handlers_cfg.and_then(|h| h.async_max_fuel),
85        ..sync_limits
86    };
87    // The **streaming** ceiling bounds a `#[handler(stream)]` response (SSE, chunked, agent
88    // token streaming): connection-bearing but long-lived, so — like the async lane — a large
89    // wall-clock on its own concurrency budget, kept apart from both the fast request pool and
90    // the durable drain.
91    let streaming_limits = boatramp_handlers::Limits {
92        timeout_ms: handlers_cfg
93            .and_then(|h| h.streaming_max_timeout_ms)
94            .unwrap_or(DEFAULT_STREAMING_TIMEOUT_MS),
95        max_concurrency: handlers_cfg
96            .and_then(|h| h.streaming_max_concurrency)
97            .unwrap_or(DEFAULT_STREAMING_CONCURRENCY),
98        fuel: handlers_cfg.and_then(|h| h.streaming_max_fuel),
99        ..sync_limits
100    };
101    let outbound_timeout = handlers_cfg
102        .and_then(|h| h.outbound_timeout_ms)
103        .map(std::time::Duration::from_millis);
104    // Opt-in pooling allocator: faster instantiation, large up-front virtual
105    // reservation — benchmark before enabling.
106    let engine = if handlers_cfg.is_some_and(|h| h.pooling) {
107        boatramp_handlers::HandlerEngine::with_pooling(sync_limits, 64)?
108    } else {
109        boatramp_handlers::HandlerEngine::new(sync_limits, 64)?
110    }
111    .with_async_limits(async_limits)
112    .with_streaming_limits(streaming_limits)
113    .with_outbound_timeout(outbound_timeout);
114    let sql = build_sql_backends(
115        handlers_cfg.and_then(|h| h.bindings.sql.as_ref()),
116        data_dir,
117        deploy,
118        &kv,
119        secrets_envelope.as_ref(),
120    )
121    .await?;
122    // The `wasi:messaging` substrate: single-node `LogMessaging` over the same
123    // blob/KV backends by default, or the cluster coordinator when one is given.
124    let messaging: Arc<dyn boatramp_core::messaging::Messaging> = messaging_override
125        .unwrap_or_else(|| {
126            Arc::new(boatramp_core::messaging::LogMessaging::new(
127                storage.clone(),
128                kv.clone(),
129            ))
130        });
131    let runtime =
132        boatramp_server::HandlerRuntime::new(engine, kv, storage, Some(sql), Some(messaging));
133    // Apply the posture's host-side blob cap + component-size cap.
134    runtime.set_max_blob_bytes(max_blob_bytes);
135    runtime.set_max_component_bytes(max_component_bytes);
136    Ok(runtime)
137}
138
139/// Resolve the `[handlers.bindings.sql]` config to the libsql SQL backend.
140/// Single-node by default (an embedded file per site under `<data-dir>`); set
141/// `url` to bind a shared sqld cluster (a namespace per site). Either way sites
142/// get a real database boundary — see `boatramp_core::sql`.
143#[cfg(feature = "handlers")]
144async fn build_sql_backends(
145    cfg: Option<&crate::config::SqlBindingConfig>,
146    data_dir: &Path,
147    deploy: &DeployStore,
148    kv: &Arc<dyn KvStore>,
149    secrets_envelope: Option<&Arc<dyn KeyEnvelope>>,
150) -> Result<Arc<dyn boatramp_core::sql::SqlBackends>> {
151    let resolve_env = |var: &Option<String>| -> Result<Option<String>> {
152        match var {
153            Some(var) => Ok(Some(
154                std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.clone()))?,
155            )),
156            None => Ok(None),
157        }
158    };
159
160    let backend = match cfg.and_then(|c| c.url.as_ref()) {
161        // Cluster: a sqld namespace per site. Auth tokens come from the
162        // environment, never the config file.
163        Some(url) => {
164            let cfg = cfg.expect("url implies cfg");
165            let admin_url = cfg.admin_url.as_ref().ok_or(Error::SqlAdminUrlRequired)?;
166            let token = resolve_env(&cfg.token_env)?.unwrap_or_default();
167            let admin_token = resolve_env(&cfg.admin_token_env)?;
168            let backends = boatramp_storage::LibsqlSqlBackends::remote(
169                url.clone(),
170                admin_url.clone(),
171                token,
172                admin_token,
173            );
174            // Optional read-replica routing: reads → replica, writes → primary.
175            match &cfg.replica_url {
176                Some(replica_url) => backends.with_read_replica(replica_url.clone()),
177                None => backends,
178            }
179        }
180        // Single-node: an embedded file per site.
181        None => {
182            let dir = cfg
183                .and_then(|c| c.dir.clone())
184                .unwrap_or_else(|| data_dir.join("handlers-sql"));
185            boatramp_storage::LibsqlSqlBackends::local(dir)
186        }
187    };
188    // Preview SQL policy (how preview deployments relate to live data).
189    let preview_mode = match cfg.and_then(|c| c.preview_mode.as_deref()) {
190        None | Some("empty") => boatramp_core::sql::PreviewSqlMode::Empty,
191        Some("branch") => boatramp_core::sql::PreviewSqlMode::Branch,
192        Some("shared") => boatramp_core::sql::PreviewSqlMode::Shared,
193        Some(other) => return Err(Error::UnknownPreviewMode(other.to_string())),
194    };
195    let preview_init = match cfg.and_then(|c| c.preview_init.as_ref()) {
196        Some(path) => {
197            Some(
198                std::fs::read_to_string(path).map_err(|err| Error::PreviewInitRead {
199                    path: path.clone(),
200                    source: err,
201                })?,
202            )
203        }
204        None => None,
205    };
206    let default: Arc<dyn boatramp_core::sql::SqlBackends> =
207        Arc::new(backend.with_preview_policy(preview_mode, preview_init));
208
209    // Overlay any external (bring-your-own) databases on the managed default.
210    // With none configured the default is returned unchanged (and a build
211    // without an external SQL engine never has to link the sqlx path).
212    let databases = cfg.map(|c| &c.databases);
213    if databases.is_none_or(std::collections::BTreeMap::is_empty) {
214        return Ok(default);
215    }
216    let databases = databases.expect("checked non-empty above");
217
218    #[cfg(any(feature = "sql-postgres", feature = "sql-mysql"))]
219    {
220        use boatramp_core::project::DEFAULT_PROJECT;
221        use boatramp_core::sql::SqlBackend;
222        use boatramp_storage::sql_compute::ComputeResolvedSqlBackend;
223        use boatramp_storage::sql_sqlx::{
224            connect, CompositeSqlBackends, ExternalSqlKind, ExternalSqlOptions,
225        };
226        let timeout = |db: &crate::config::ExternalDatabaseConfig| {
227            db.connect_timeout_secs.map(std::time::Duration::from_secs)
228        };
229        let mut composite = CompositeSqlBackends::new(default);
230        for (name, db) in databases {
231            let kind = ExternalSqlKind::parse(&db.kind).ok_or_else(|| Error::SqlExternalKind {
232                name: name.clone(),
233                kind: db.kind.clone(),
234            })?;
235            let external: Arc<dyn SqlBackend> = if let Some(workload) =
236                db.compute.as_deref().filter(|c| !c.is_empty())
237            {
238                // Compute-backed: resolve the workload's live endpoint on demand and
239                // build the connection. The credential is either brought
240                // (`password_env`) or **boatramp-managed** (generated + sealed).
241                let password = match db.password_env.as_deref().filter(|v| !v.is_empty()) {
242                    Some(var) => std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.into()))?,
243                    None => {
244                        // Managed credential: fail closed without a secrets envelope
245                        // (we will not persist a DB password in cleartext).
246                        let envelope = secrets_envelope
247                            .cloned()
248                            .ok_or_else(|| Error::SqlManagedNeedsSecrets(name.clone()))?;
249                        crate::managed_sql::ManagedSqlCredentials::new(kv.clone(), envelope)
250                            .password(DEFAULT_PROJECT, workload)
251                            .await
252                            .map_err(|reason| Error::SqlManagedCredential {
253                                name: name.clone(),
254                                reason,
255                            })?
256                    }
257                };
258                let resolver = Arc::new(crate::managed_sql::DeployEndpointResolver::new(
259                    deploy.clone(),
260                    DEFAULT_PROJECT,
261                ));
262                Arc::new(ComputeResolvedSqlBackend::new(
263                    resolver,
264                    workload,
265                    kind,
266                    db.database.clone().unwrap_or_default(),
267                    db.user.clone().unwrap_or_default(),
268                    password,
269                    db.pool_max,
270                    db.read_only,
271                    timeout(db),
272                ))
273            } else {
274                // Bring-your-own URL: the connection URL(s) are secrets, resolved
275                // from the environment.
276                if db.url_env.trim().is_empty() {
277                    return Err(Error::SqlExternalUrlEnvMissing(name.clone()));
278                }
279                let url = std::env::var(&db.url_env)
280                    .map_err(|_| Error::SqlEnvUnset(db.url_env.clone()))?;
281                let read_url = match &db.read_url_env {
282                    Some(var) => {
283                        Some(std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.clone()))?)
284                    }
285                    None => None,
286                };
287                let opts = ExternalSqlOptions::new(url)
288                    .with_read_url(read_url)
289                    .with_max_connections(db.pool_max)
290                    .read_only(db.read_only)
291                    .with_connect_timeout(timeout(db));
292                connect(kind, &opts).map_err(|source| Error::SqlExternalConnect {
293                    name: name.clone(),
294                    source,
295                })?
296            };
297            composite = composite.with_external(name.clone(), external, db.allow_preview);
298        }
299        Ok(Arc::new(composite))
300    }
301    #[cfg(not(any(feature = "sql-postgres", feature = "sql-mysql")))]
302    {
303        // The compute-backed arm (the only consumer of these) is compiled out
304        // without a SQL engine; a `databases` entry then can't be served at all.
305        let _ = (deploy, kv, secrets_envelope);
306        let name = databases.keys().next().cloned().unwrap_or_default();
307        Err(Error::SqlExternalUnavailable(name))
308    }
309}
310
311#[cfg(not(feature = "handlers"))]
312#[allow(clippy::too_many_arguments)]
313pub async fn build_handler_runtime(
314    _kv: Arc<dyn KvStore>,
315    _storage: Arc<dyn boatramp_core::Storage>,
316    _data_dir: &Path,
317    _handlers_cfg: Option<&crate::config::HandlersConfig>,
318    _messaging_override: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
319    _max_blob_bytes: u64,
320    _max_component_bytes: u64,
321    _deploy: &DeployStore,
322    _secrets_envelope: Option<Arc<dyn KeyEnvelope>>,
323) -> Result<boatramp_server::HandlerRuntime> {
324    Ok(boatramp_server::HandlerRuntime::disabled())
325}
326
327#[cfg(all(test, any(feature = "sql-postgres", feature = "sql-mysql")))]
328mod tests {
329    use super::*;
330    // `super::*` brings the crate's 1-arg `Result` alias into scope; the trait impls
331    // below need the std 2-arg `Result`, so shadow it back (explicit beats glob).
332    use std::result::Result;
333
334    use async_trait::async_trait;
335    use boatramp_core::envelope::EnvelopeError;
336    use boatramp_core::kv::MemoryKv;
337    use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
338
339    /// A reversible test envelope (NOT encryption) — proves sealing round-trips.
340    struct TestEnvelope;
341    #[async_trait]
342    impl KeyEnvelope for TestEnvelope {
343        async fn wrap(&self, p: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
344            Ok(p.iter().rev().copied().collect())
345        }
346        async fn unwrap(&self, w: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
347            Ok(w.iter().rev().copied().collect())
348        }
349    }
350
351    /// A no-op object store, so a `DeployStore` can be built (the endpoint resolver
352    /// only reads KV replica state, which is empty here — the backend is lazy).
353    struct NullStorage;
354    #[async_trait]
355    impl Storage for NullStorage {
356        async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
357            Err(StorageError::NotFound(String::new()))
358        }
359        async fn get_range(
360            &self,
361            _: &str,
362            _: u64,
363            _: Option<u64>,
364        ) -> Result<GetObject, StorageError> {
365            Err(StorageError::NotFound(String::new()))
366        }
367        async fn put(
368            &self,
369            _: &str,
370            _: ByteStream,
371            _: PutMeta,
372        ) -> Result<ObjectMeta, StorageError> {
373            Err(StorageError::unsupported("null"))
374        }
375        async fn head(&self, _: &str) -> Result<ObjectMeta, StorageError> {
376            Err(StorageError::NotFound(String::new()))
377        }
378        async fn delete(&self, _: &str) -> Result<(), StorageError> {
379            Ok(())
380        }
381        async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
382            Ok(Vec::new())
383        }
384    }
385
386    /// A `sql` binding with one managed (compute-backed, no `password_env`) database.
387    fn managed_sql_cfg() -> crate::config::SqlBindingConfig {
388        let mut databases = std::collections::BTreeMap::new();
389        databases.insert(
390            "analytics".to_string(),
391            crate::config::ExternalDatabaseConfig {
392                kind: "postgres".into(),
393                compute: Some("pg".into()),
394                database: Some("analytics".into()),
395                user: Some("app".into()),
396                ..Default::default()
397            },
398        );
399        crate::config::SqlBindingConfig {
400            databases,
401            ..Default::default()
402        }
403    }
404
405    #[tokio::test]
406    async fn managed_sql_fails_closed_without_secrets() {
407        let tmp = tempfile::tempdir().unwrap();
408        let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
409        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
410        let cfg = managed_sql_cfg();
411        // `Arc<dyn SqlBackends>` isn't `Debug`, so match rather than `unwrap_err`.
412        match build_sql_backends(Some(&cfg), tmp.path(), &deploy, &kv, None).await {
413            Err(Error::SqlManagedNeedsSecrets(name)) => assert_eq!(name, "analytics"),
414            Ok(_) => panic!("a managed DB without [secrets] must fail closed, got Ok"),
415            Err(other) => panic!("expected SqlManagedNeedsSecrets, got: {other}"),
416        }
417    }
418
419    #[tokio::test]
420    async fn managed_sql_builds_lazily_and_seals_the_credential() {
421        let tmp = tempfile::tempdir().unwrap();
422        let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
423        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
424        let envelope: Arc<dyn KeyEnvelope> = Arc::new(TestEnvelope);
425        let cfg = managed_sql_cfg();
426        // No DB is running: the backend resolves the endpoint on first use, so
427        // assembly succeeds without a connection and the credential is sealed now.
428        let backends = build_sql_backends(Some(&cfg), tmp.path(), &deploy, &kv, Some(&envelope))
429            .await
430            .expect("managed sql builds without a live DB (lazy connect)");
431        let sealed = kv
432            .get("managed-sql-cred/default/pg")
433            .await
434            .unwrap()
435            .expect("managed credential sealed at build under the default project");
436        assert_ne!(sealed.len(), 0);
437        // Sanity: the composite is usable as a provider (no connection yet).
438        let _: Arc<dyn boatramp_core::sql::SqlBackends> = backends;
439    }
440}