Skip to main content

aion_server/
state.rs

1//! Shared server state constructed once at startup.
2
3use std::{path::PathBuf, sync::Arc};
4
5use aion::{
6    ActivityDispatcher, EngineBuilder, RuntimeHandle, SignalRouter, signal::ConcreteSignalRouter,
7};
8use aion_store::{EventStore, OutboxStore};
9use aion_store_libsql::LibSqlStore;
10
11use crate::dev_ui::{ActivityMockRegistry, DevMockingDispatcher};
12
13#[cfg(feature = "auth")]
14use crate::auth::JwksCache;
15use crate::{
16    config::{RuntimeConfig, ServerConfig, StoreBackend, StoreConfig},
17    error::ServerError,
18    namespace::{NamespaceGuard, resolver::NamespaceResolver},
19    observability::{
20        Metrics, health::HealthState, instrumented_store::InstrumentedEventStore,
21        metrics::MetricsError,
22    },
23    shutdown::DrainState,
24    worker::{
25        ConnectedWorkerRegistry, HeartbeatTracker, PendingActivities, WorkerActivityDispatcher,
26    },
27};
28
29/// Cloneable shared state passed to all server transports.
30#[derive(Clone)]
31pub struct ServerState {
32    inner: Arc<ServerStateInner>,
33}
34
35struct ServerStateInner {
36    namespace_guard: NamespaceGuard,
37    runtime: RuntimeConfig,
38    worker_registry: ConnectedWorkerRegistry,
39    pending_activities: PendingActivities,
40    heartbeat_tracker: HeartbeatTracker,
41    drain_state: DrainState,
42    metrics: Option<Metrics>,
43    health: Option<HealthState>,
44    /// Shared per-run activity-mock registry. Present only when the dev surface
45    /// is commissioned; the engine's dispatcher consults this exact instance.
46    activity_mock_registry: Option<ActivityMockRegistry>,
47    /// The leaf libSQL store cast as an [`OutboxStore`], shared with the engine's
48    /// `EventStore` so the outbox dispatcher writes through the same single
49    /// `libsql::Connection`. `None` for the in-memory backend (no outbox table).
50    outbox_store: Option<Arc<dyn OutboxStore>>,
51    /// Advisory outbox wake (LSUB-2): the in-process `Notify` shared by the
52    /// engine's stage seam (the `InstrumentedEventStore`'s `append_with_outbox`)
53    /// and the [`OutboxDispatcher`](crate::worker::OutboxDispatcher) run loop, so
54    /// a committed fan-out row wakes the dispatcher in ~RTT instead of waiting up
55    /// to one poll interval. Always present (cheap, no `Option`): the handle is
56    /// harmless when the outbox is not commissioned, since nothing pulses it.
57    outbox_wake: Arc<tokio::sync::Notify>,
58    /// Owns the distributed haematite inbound-write responder thread, kept alive
59    /// for the server's lifetime so a cluster node keeps answering peers'
60    /// replication/election traffic. `None` for non-distributed boots. Dropping
61    /// the state stops the responder.
62    #[cfg(feature = "haematite-backend")]
63    cluster_responder: Option<aion_store_haematite::ClusterResponder>,
64    /// The concrete distributed haematite store the SS-5b supervisor polls for
65    /// peer liveness. `None` for every non-distributed boot.
66    #[cfg(feature = "haematite-backend")]
67    cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
68    /// The peers the SS-5b supervisor watches (each with the shards this node
69    /// adopts on its death). Empty for non-distributed boots.
70    #[cfg(feature = "haematite-backend")]
71    watched_peers: Vec<crate::cluster::WatchedPeer>,
72    /// The request-routing shard directory (R-2), built over the cluster store +
73    /// static peer config. `None` for every non-distributed boot, so the routing
74    /// edge falls back to the bare R-1 ownership check (and the default path is a
75    /// no-op).
76    #[cfg(feature = "haematite-backend")]
77    shard_directory: Option<Arc<crate::routing::StaticShardDirectory>>,
78    /// The request forwarder (R-3): relays a non-local signal/query/cancel to the
79    /// shard owner's gRPC address. `None` for non-distributed boots. The trait
80    /// object makes the liminal forwarder a one-line swap when 13-L0/L1 land (R-6).
81    #[cfg(feature = "haematite-backend")]
82    request_forwarder: Option<Arc<dyn crate::routing::RequestForwarder>>,
83    #[cfg(feature = "auth")]
84    jwks_cache: Option<JwksCache>,
85}
86
87impl ServerState {
88    /// Build shared state from operator configuration.
89    ///
90    /// # Errors
91    ///
92    /// Returns [`ServerError`] if the store cannot connect or the engine cannot
93    /// be constructed.
94    pub async fn build(config: ServerConfig) -> Result<Self, ServerError> {
95        let (store_config, runtime) = config.into_parts();
96        let connected = connect_store(store_config).await?;
97        Self::build_with_connected_store(connected, runtime).await
98    }
99
100    /// Build shared state from an already-constructed store.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`ServerError::EngineCall`] if the engine cannot be constructed.
105    pub async fn build_with_store<S>(store: S, runtime: RuntimeConfig) -> Result<Self, ServerError>
106    where
107        S: EventStore,
108    {
109        Self::build_with_connected_store(ConnectedStore::local(Arc::new(store), None), runtime)
110            .await
111    }
112
113    async fn build_with_connected_store(
114        connected: ConnectedStore,
115        runtime: RuntimeConfig,
116    ) -> Result<Self, ServerError> {
117        let store = connected.event_store;
118        let outbox_store = connected.outbox_store;
119        let bootstrap_coordinator = connected.bootstrap_coordinator;
120        #[cfg(feature = "haematite-backend")]
121        let cluster_responder = connected.cluster_responder;
122        #[cfg(feature = "haematite-backend")]
123        let cluster_store = connected.cluster_store;
124        #[cfg(feature = "haematite-backend")]
125        let watched_peers = connected.watched_peers;
126        // Build the R-2 directory + R-3 forwarder over the (live, failover-aware)
127        // cluster store and static peer config. Both present only for a
128        // distributed boot; `None` otherwise leaves the routing edge a no-op.
129        #[cfg(feature = "haematite-backend")]
130        let RoutingState {
131            shard_directory,
132            request_forwarder,
133        } = build_routing_state(
134            cluster_store.as_ref(),
135            connected.directory_peers,
136            connected.self_node_id,
137        );
138        let (event_broadcast_capacity, query_timeout) = required_engine_seams(&runtime)?;
139        let metrics = Metrics::new().map_err(|error| metrics_config_error(&error))?;
140        // LSUB-2 advisory wake: one process-wide `Notify` shared by the engine's
141        // stage seam and the outbox dispatcher. A single handle is correct here
142        // because there is exactly one in-process dispatcher that sweeps all owned
143        // shards per tick — a wake just means "something was staged; sweep".
144        let outbox_wake = Arc::new(tokio::sync::Notify::new());
145        let instrumented_store = Arc::new(
146            InstrumentedEventStore::new(
147                store.clone(),
148                metrics.clone(),
149                runtime.default_namespace.clone(),
150            )
151            .with_outbox_wake(Arc::clone(&outbox_wake)),
152        );
153        let exported_metrics = runtime.metrics.enabled.then_some(metrics.clone());
154        let worker_registry = ConnectedWorkerRegistry::default();
155        let active_registry = Arc::new(aion::Registry::default());
156        let pending_activities = PendingActivities::default();
157        let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
158        let drain_state = DrainState::default();
159        let dispatcher = WorkerActivityDispatcher::new(
160            worker_registry.clone(),
161            runtime.default_namespace.clone(),
162            heartbeat_tracker.clone(),
163        )
164        .with_pending(pending_activities.clone())
165        .with_drain_state(drain_state.clone())
166        .with_tokio_handle(tokio::runtime::Handle::current());
167        // Dark by default: only when the dev surface is commissioned does the
168        // engine receive the per-run activity-mock decorator. With it off the
169        // engine gets the bare production dispatcher, so a production server has
170        // no mocking path at all (CN4).
171        let (activity_dispatcher, activity_mock_registry): (Arc<dyn ActivityDispatcher>, _) =
172            if runtime.dev.enabled {
173                let registry = ActivityMockRegistry::new();
174                let decorated = DevMockingDispatcher::new(Arc::new(dispatcher), registry.clone());
175                (Arc::new(decorated), Some(registry))
176            } else {
177                (Arc::new(dispatcher), None)
178            };
179
180        let engine = build_engine(EngineAssembly {
181            instrumented_store: &instrumented_store,
182            event_broadcast_capacity,
183            query_timeout,
184            activity_dispatcher,
185            active_registry,
186            bootstrap_coordinator,
187            runtime: &runtime,
188        })
189        .await?;
190        let engine = Arc::new(engine);
191        // Outbox ON: route unmatched worker completions arriving at the sink
192        // into the live workflow's mailbox. Flag-off this callback is never
193        // installed, so the sink's unmatched branch stays a silent drop. The
194        // dispatcher is not rebuilt — it shares this exact pending tracker.
195        if runtime.outbox.enabled {
196            let callback = Arc::new(crate::worker::ServerOutboxDeliveryCallback::new(
197                Arc::clone(&engine),
198            ));
199            pending_activities.set_outbox_delivery(callback);
200        }
201        let namespace_resolver = NamespaceResolver::from_config(runtime.namespace.clone(), engine);
202        #[cfg(feature = "auth")]
203        let jwks_cache = build_jwks_cache(&runtime).await?;
204        Ok(Self {
205            inner: Arc::new(ServerStateInner {
206                namespace_guard: NamespaceGuard::new(namespace_resolver),
207                runtime,
208                worker_registry,
209                pending_activities,
210                heartbeat_tracker,
211                drain_state,
212                metrics: exported_metrics,
213                health: Some(HealthState::new(instrumented_store, true)),
214                activity_mock_registry,
215                outbox_store,
216                outbox_wake,
217                #[cfg(feature = "haematite-backend")]
218                cluster_responder,
219                #[cfg(feature = "haematite-backend")]
220                cluster_store,
221                #[cfg(feature = "haematite-backend")]
222                watched_peers,
223                #[cfg(feature = "haematite-backend")]
224                shard_directory,
225                #[cfg(feature = "haematite-backend")]
226                request_forwarder,
227                #[cfg(feature = "auth")]
228                jwks_cache,
229            }),
230        })
231    }
232
233    /// Build shared state from explicit parts with a default worker registry.
234    #[must_use]
235    pub fn from_parts(namespace_resolver: NamespaceResolver, runtime: RuntimeConfig) -> Self {
236        let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
237        Self {
238            inner: Arc::new(ServerStateInner {
239                namespace_guard: NamespaceGuard::new(namespace_resolver),
240                runtime,
241                worker_registry: ConnectedWorkerRegistry::default(),
242                pending_activities: PendingActivities::default(),
243                heartbeat_tracker,
244                drain_state: DrainState::default(),
245                metrics: None,
246                health: None,
247                activity_mock_registry: None,
248                outbox_store: None,
249                outbox_wake: Arc::new(tokio::sync::Notify::new()),
250                #[cfg(feature = "haematite-backend")]
251                cluster_responder: None,
252                #[cfg(feature = "haematite-backend")]
253                cluster_store: None,
254                #[cfg(feature = "haematite-backend")]
255                watched_peers: Vec::new(),
256                #[cfg(feature = "haematite-backend")]
257                shard_directory: None,
258                #[cfg(feature = "haematite-backend")]
259                request_forwarder: None,
260                #[cfg(feature = "auth")]
261                jwks_cache: None,
262            }),
263        }
264    }
265
266    /// Build shared state from explicit parts with a caller-supplied JWKS cache.
267    ///
268    /// Embedders that construct their own [`JwksCache`] (for example against a
269    /// private issuer) can install it here; transports then validate bearer
270    /// tokens against it exactly as with a [`Self::build`]-constructed state.
271    #[cfg(feature = "auth")]
272    #[must_use]
273    pub fn from_parts_with_jwks(
274        namespace_resolver: NamespaceResolver,
275        runtime: RuntimeConfig,
276        jwks_cache: JwksCache,
277    ) -> Self {
278        let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
279        Self {
280            inner: Arc::new(ServerStateInner {
281                namespace_guard: NamespaceGuard::new(namespace_resolver),
282                runtime,
283                worker_registry: ConnectedWorkerRegistry::default(),
284                pending_activities: PendingActivities::default(),
285                heartbeat_tracker,
286                drain_state: DrainState::default(),
287                metrics: None,
288                health: None,
289                activity_mock_registry: None,
290                outbox_store: None,
291                outbox_wake: Arc::new(tokio::sync::Notify::new()),
292                #[cfg(feature = "haematite-backend")]
293                cluster_responder: None,
294                #[cfg(feature = "haematite-backend")]
295                cluster_store: None,
296                #[cfg(feature = "haematite-backend")]
297                watched_peers: Vec::new(),
298                #[cfg(feature = "haematite-backend")]
299                shard_directory: None,
300                #[cfg(feature = "haematite-backend")]
301                request_forwarder: None,
302                jwks_cache: Some(jwks_cache),
303            }),
304        }
305    }
306
307    /// Build shared state from explicit parts with a caller-supplied registry.
308    #[must_use]
309    pub fn from_parts_with_registry(
310        namespace_resolver: NamespaceResolver,
311        runtime: RuntimeConfig,
312        worker_registry: ConnectedWorkerRegistry,
313    ) -> Self {
314        let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
315        Self {
316            inner: Arc::new(ServerStateInner {
317                namespace_guard: NamespaceGuard::new(namespace_resolver),
318                runtime,
319                worker_registry,
320                pending_activities: PendingActivities::default(),
321                heartbeat_tracker,
322                drain_state: DrainState::default(),
323                metrics: None,
324                health: None,
325                activity_mock_registry: None,
326                outbox_store: None,
327                outbox_wake: Arc::new(tokio::sync::Notify::new()),
328                #[cfg(feature = "haematite-backend")]
329                cluster_responder: None,
330                #[cfg(feature = "haematite-backend")]
331                cluster_store: None,
332                #[cfg(feature = "haematite-backend")]
333                watched_peers: Vec::new(),
334                #[cfg(feature = "haematite-backend")]
335                shard_directory: None,
336                #[cfg(feature = "haematite-backend")]
337                request_forwarder: None,
338                #[cfg(feature = "auth")]
339                jwks_cache: None,
340            }),
341        }
342    }
343
344    /// Borrow the namespace guard shared by all transports.
345    #[must_use]
346    pub fn namespace_guard(&self) -> &NamespaceGuard {
347        &self.inner.namespace_guard
348    }
349
350    /// Build the deploy authorization guard over the shared resolver.
351    #[must_use]
352    pub fn deploy_guard(&self) -> crate::deploy::DeployGuard {
353        crate::deploy::DeployGuard::new(self.inner.namespace_guard.resolver().clone())
354    }
355
356    /// Borrow non-secret runtime settings needed by transports.
357    #[must_use]
358    pub fn runtime_config(&self) -> &RuntimeConfig {
359        &self.inner.runtime
360    }
361
362    /// Borrow the connected-worker registry shared by worker transports and dispatch.
363    #[must_use]
364    pub fn worker_registry(&self) -> &ConnectedWorkerRegistry {
365        &self.inner.worker_registry
366    }
367
368    /// Clone the live engine handle the completion path records terminals through.
369    ///
370    /// This is the SAME `Arc<Engine>` the gRPC completion callback is built over
371    /// (state.rs installs `ServerOutboxDeliveryCallback::new(engine)` on the
372    /// pending tracker when `outbox.enabled`), so the liminal completion path
373    /// re-enters worker results through the identical `record_fan_out_completion`
374    /// seam rather than inventing a second one.
375    ///
376    /// # Errors
377    ///
378    /// Returns [`ServerError`] when the namespace resolver has no engine handle
379    /// (a state built from parts without an engine).
380    pub fn engine(&self) -> Result<Arc<aion::Engine>, ServerError> {
381        self.inner
382            .namespace_guard
383            .resolver()
384            .engine()
385            .map(Arc::clone)
386    }
387
388    /// Borrow the pending-activities tracker shared by the NIF bridge and worker stream handler.
389    #[must_use]
390    pub fn pending_activities(&self) -> &PendingActivities {
391        &self.inner.pending_activities
392    }
393
394    /// Borrow the heartbeat/liveness tracker shared by dispatch and worker streams.
395    #[must_use]
396    pub fn heartbeat_tracker(&self) -> &HeartbeatTracker {
397        &self.inner.heartbeat_tracker
398    }
399
400    /// Borrow the drain gate shared by transports and worker dispatch.
401    #[must_use]
402    pub fn drain_state(&self) -> &DrainState {
403        &self.inner.drain_state
404    }
405
406    /// Borrow the prometheus metrics handle when this state was built with a store.
407    #[must_use]
408    pub fn metrics(&self) -> Option<&Metrics> {
409        self.inner.metrics.as_ref()
410    }
411
412    /// Borrow health probe state when this state was built with a store.
413    #[must_use]
414    pub fn health(&self) -> Option<&HealthState> {
415        self.inner.health.as_ref()
416    }
417
418    /// Borrow the shared per-run activity-mock registry when the dev surface is
419    /// commissioned. Returns [`None`] on a server with the dev surface dark, so
420    /// the dev handlers refuse cleanly rather than mocking on a production
421    /// server.
422    #[must_use]
423    pub fn activity_mock_registry(&self) -> Option<&ActivityMockRegistry> {
424        self.inner.activity_mock_registry.as_ref()
425    }
426
427    /// Borrow the outbox store the dispatcher claims rows from, when the durable
428    /// (libSQL) backend is in use. This is the SAME leaf `Arc<LibSqlStore>` the
429    /// engine writes through, so the dispatcher shares its single
430    /// `libsql::Connection` rather than opening a second contending one. Returns
431    /// [`None`] for the in-memory backend, which has no outbox table.
432    #[must_use]
433    pub fn outbox_store(&self) -> Option<Arc<dyn OutboxStore>> {
434        self.inner.outbox_store.clone()
435    }
436
437    /// Clone the advisory outbox wake (LSUB-2) shared with the engine's stage
438    /// seam. The outbox dispatcher installs this handle so a committed fan-out row
439    /// wakes its run loop in ~RTT rather than waiting for the next poll tick. The
440    /// handle is always present; it is simply never pulsed when the outbox is not
441    /// commissioned, so wiring it is free and behaviour is unchanged.
442    #[must_use]
443    pub fn outbox_wake(&self) -> Arc<tokio::sync::Notify> {
444        Arc::clone(&self.inner.outbox_wake)
445    }
446
447    /// Whether this server is a node in a distributed haematite cluster.
448    ///
449    /// `true` when boot constructed the distributed backend (a `[store.cluster]`
450    /// section was present) and is holding its inbound-write responder alive;
451    /// `false` for every single-node / non-haematite boot.
452    #[cfg(feature = "haematite-backend")]
453    #[must_use]
454    pub fn is_clustered(&self) -> bool {
455        self.inner.cluster_responder.is_some()
456    }
457
458    /// The concrete distributed haematite store the request-routing edge consults
459    /// for shard ownership (`shard_for_workflow` / `owns_workflow_shard`) and
460    /// unsteered-start remint. `None` for every single-node / non-clustered boot,
461    /// so the routing pre-step is a no-op and the default path is unchanged.
462    #[cfg(feature = "haematite-backend")]
463    #[must_use]
464    pub fn cluster_store(&self) -> Option<&Arc<aion_store_haematite::HaematiteStore>> {
465        self.inner.cluster_store.as_ref()
466    }
467
468    /// The request-routing shard directory (R-2) the edge consults to resolve a
469    /// non-owned shard's owner. `None` for single-node / non-clustered boots, so
470    /// the edge falls back to the bare R-1 ownership check.
471    #[cfg(feature = "haematite-backend")]
472    #[must_use]
473    pub fn shard_directory(&self) -> Option<&Arc<crate::routing::StaticShardDirectory>> {
474        self.inner.shard_directory.as_ref()
475    }
476
477    /// The R-3 request forwarder used to relay a non-local signal/query/cancel to
478    /// the shard owner. `None` for single-node / non-clustered boots.
479    #[cfg(feature = "haematite-backend")]
480    #[must_use]
481    pub fn request_forwarder(&self) -> Option<&Arc<dyn crate::routing::RequestForwarder>> {
482        self.inner.request_forwarder.as_ref()
483    }
484
485    /// Spawn the SS-5b cluster supervisor: a background task that watches every
486    /// declared peer's replication liveness and, on a confirmed peer death,
487    /// calls `adopt_shards` for that peer's shards on THIS node's live engine —
488    /// automatic failover with no manual trigger.
489    ///
490    /// Does nothing (returns `Ok(())` without spawning) unless this is a
491    /// distributed boot whose cluster config declared at least one peer with
492    /// `owned_shards`. A single-node / non-clustered server therefore never runs
493    /// a supervisor, so default behaviour is unchanged.
494    ///
495    /// The spawned task drains on `shutdown` exactly like the transports.
496    ///
497    /// # Errors
498    ///
499    /// Returns [`ServerError`] when the engine handle cannot be resolved.
500    #[cfg(feature = "haematite-backend")]
501    pub fn spawn_cluster_supervisor(
502        &self,
503        config: crate::cluster::SupervisorConfig,
504        shutdown: tokio::sync::watch::Receiver<bool>,
505    ) -> Result<bool, ServerError> {
506        let Some(cluster_store) = self.inner.cluster_store.clone() else {
507            return Ok(false);
508        };
509        if self.inner.watched_peers.is_empty() {
510            return Ok(false);
511        }
512        let engine = Arc::clone(self.inner.namespace_guard.resolver().engine()?);
513        let supervisor = crate::cluster::ClusterSupervisor::new(
514            cluster_store,
515            engine,
516            self.inner.watched_peers.clone(),
517            config,
518        );
519        if !supervisor.watches_any() {
520            return Ok(false);
521        }
522        tokio::spawn(supervisor.run(shutdown));
523        Ok(true)
524    }
525
526    /// Borrow the shared JWKS cache when authentication is enabled.
527    #[cfg(feature = "auth")]
528    #[must_use]
529    pub fn jwks_cache(&self) -> Option<&JwksCache> {
530        self.inner.jwks_cache.as_ref()
531    }
532
533    /// Shut down the embedded engine so in-flight durable appends can finish.
534    ///
535    /// # Errors
536    ///
537    /// Returns [`ServerError`] if the namespace resolver has no engine handle or the engine rejects
538    /// shutdown.
539    pub fn shutdown(&self) -> Result<(), ServerError> {
540        self.inner.namespace_guard.resolver().shutdown_engine()
541    }
542}
543
544#[cfg(feature = "auth")]
545async fn build_jwks_cache(runtime: &RuntimeConfig) -> Result<Option<JwksCache>, ServerError> {
546    if !runtime.auth.enabled {
547        return Ok(None);
548    }
549    let Some(url) = runtime.auth.jwks_url.clone() else {
550        return Err(ServerError::Config {
551            message: "auth.jwks_url must not be empty when auth.enabled is true".to_owned(),
552        });
553    };
554    let interval = std::time::Duration::from_secs(runtime.auth.jwks_refresh_seconds);
555    let cache = JwksCache::new(url, interval)
556        .await
557        .map_err(|error| ServerError::Config {
558            message: format!("auth jwks initial fetch failed: {error}"),
559        })?;
560    Ok(Some(cache))
561}
562
563fn metrics_config_error(error: &MetricsError) -> ServerError {
564    ServerError::Config {
565        message: error.to_string(),
566    }
567}
568
569/// Borrowed inputs assembled into the embedded engine by [`build_engine`].
570struct EngineAssembly<'a> {
571    /// The metrics-instrumented store the engine writes through.
572    instrumented_store: &'a Arc<InstrumentedEventStore>,
573    /// Explicitly-sized broadcast channel capacity for `/events/stream`.
574    event_broadcast_capacity: std::num::NonZeroUsize,
575    /// Explicit workflow-query reply deadline for `/workflows/query`.
576    query_timeout: std::time::Duration,
577    /// The activity dispatcher (optionally dev-mock-decorated) the engine uses.
578    activity_dispatcher: Arc<dyn ActivityDispatcher>,
579    /// The shared active-workflow registry server dispatchers correlate against.
580    active_registry: Arc<aion::Registry>,
581    /// Whether THIS node seeds the schedule coordinator (SS-2 ownership gate).
582    bootstrap_coordinator: bool,
583    /// Non-secret runtime settings driving scheduler/outbox/package/shard knobs.
584    runtime: &'a RuntimeConfig,
585}
586
587/// Assemble the embedded engine from the server's runtime configuration.
588///
589/// Factored out of [`ServerState::build_with_connected_store`] to keep that
590/// method within length bounds; it carries the SS-2 wiring — the coordinator
591/// bootstrap gate fed from real ownership and the `owned_shards` hook that drives
592/// both scoping and the per-shard election before recovery.
593async fn build_engine(assembly: EngineAssembly<'_>) -> Result<aion::Engine, ServerError> {
594    let mut search_attribute_schema = aion_core::SearchAttributeSchema::new();
595    search_attribute_schema
596        .register(
597            crate::namespace::NAMESPACE_ATTRIBUTE,
598            aion_core::SearchAttributeType::String,
599        )
600        .map_err(|error| ServerError::Config {
601            message: format!("failed to register namespace search attribute: {error}"),
602        })?;
603    let runtime = assembly.runtime;
604    let builder = EngineBuilder::new()
605        .store_arc(assembly.instrumented_store.clone())
606        .event_streaming(assembly.event_broadcast_capacity)
607        .in_memory_visibility()
608        .search_attribute_schema(search_attribute_schema)
609        .scheduler_threads(runtime.scheduler_threads)
610        .outbox_enabled(runtime.outbox.enabled)
611        .activity_dispatcher(assembly.activity_dispatcher)
612        .active_registry(assembly.active_registry)
613        .production_recovery_seam()
614        .signal_router_factory(|runtime: Arc<RuntimeHandle>, handoff| {
615            Arc::new(ConcreteSignalRouter::new(runtime, handoff)) as Arc<dyn SignalRouter>
616        })
617        .query_timeout(assembly.query_timeout)
618        // SS-2: only the node owning the schedule-coordinator's shard seeds and
619        // serves it. `true` for every non-distributed boot (owns all shards); a
620        // distributed non-owner passes `false` so it does not fence the
621        // coordinator stream (AA-4-4). Default `true`, so a single-node boot is
622        // byte-identical to today.
623        .bootstrap_schedule_coordinator(assembly.bootstrap_coordinator)
624        .load_workflow_sources(runtime.workflow_packages.iter().map(PathBuf::as_path));
625    // Owned-shard assignment: when the operator pins this node to a shard subset,
626    // scope the engine to it AND (SS-2) elect those shards before recovery — the
627    // builder's `owned_shards` hook drives both. Empty (the default) leaves the
628    // builder untouched, so single-node boot owns ALL shards, elects nothing, and
629    // is byte-identical to today.
630    let builder = if runtime.owned_shards.is_empty() {
631        builder
632    } else {
633        builder.owned_shards(runtime.owned_shards.iter().copied())
634    };
635    builder.build().await.map_err(ServerError::from)
636}
637
638/// Validate the two engine seams the server unconditionally mounts: the event
639/// broadcast channel capacity (`/events/stream`) and the query reply deadline
640/// (`/workflows/query`). Both are explicit-no-default — a mounted-but-
641/// unconfigured surface is never acceptable.
642fn required_engine_seams(
643    runtime: &RuntimeConfig,
644) -> Result<(std::num::NonZeroUsize, std::time::Duration), ServerError> {
645    let event_broadcast_capacity = runtime
646        .websocket
647        .event_broadcast_capacity
648        .and_then(std::num::NonZeroUsize::new)
649        .ok_or_else(|| ServerError::Config {
650            message: crate::config::EVENT_BROADCAST_CAPACITY_REQUIRED.to_owned(),
651        })?;
652    let query_timeout = runtime
653        .query_timeout
654        .filter(|timeout| !timeout.is_zero())
655        .ok_or_else(|| ServerError::Config {
656            message: crate::config::QUERY_TIMEOUT_REQUIRED.to_owned(),
657        })?;
658    Ok((event_broadcast_capacity, query_timeout))
659}
660
661/// The request-routing pieces built from the cluster store + peer config.
662#[cfg(feature = "haematite-backend")]
663struct RoutingState {
664    shard_directory: Option<Arc<crate::routing::StaticShardDirectory>>,
665    request_forwarder: Option<Arc<dyn crate::routing::RequestForwarder>>,
666}
667
668/// Build the R-2 shard directory and R-3 request forwarder over the cluster
669/// store and static peer config, or all-`None` when this is not a distributed
670/// boot (no cluster store) so the routing edge is a no-op (default path).
671#[cfg(feature = "haematite-backend")]
672fn build_routing_state(
673    cluster_store: Option<&Arc<aion_store_haematite::HaematiteStore>>,
674    directory_peers: Vec<crate::routing::DirectoryPeer>,
675    self_node_id: Option<String>,
676) -> RoutingState {
677    let Some(store) = cluster_store else {
678        return RoutingState {
679            shard_directory: None,
680            request_forwarder: None,
681        };
682    };
683    RoutingState {
684        shard_directory: Some(Arc::new(crate::routing::StaticShardDirectory::new(
685            Arc::clone(store),
686            directory_peers,
687            self_node_id,
688        ))),
689        request_forwarder: Some(Arc::new(crate::routing::GrpcRequestForwarder::new())),
690    }
691}
692
693/// A connected durable store plus the lifecycle pieces the boot path needs.
694///
695/// `outbox_store` is the SAME leaf store cast as an [`OutboxStore`] for backends
696/// with a durable outbox table (libSQL, haematite); the in-memory backend yields
697/// `None`. `bootstrap_coordinator` gates the schedule-coordinator seed on real
698/// ownership (SS-2 / AA-4-4): `true` for every non-distributed boot (single-node
699/// owns the coordinator's shard), and for a distributed node only when it owns
700/// that shard. `cluster_responder` owns the distributed inbound-write responder
701/// thread, kept alive for the server's lifetime; `None` for non-distributed boots.
702struct ConnectedStore {
703    event_store: Arc<dyn EventStore>,
704    outbox_store: Option<Arc<dyn OutboxStore>>,
705    bootstrap_coordinator: bool,
706    #[cfg(feature = "haematite-backend")]
707    cluster_responder: Option<aion_store_haematite::ClusterResponder>,
708    /// The concrete distributed haematite store (the SAME leaf as `event_store`),
709    /// retained for the SS-5b cluster supervisor's peer-liveness polling. `None`
710    /// for every non-distributed boot.
711    #[cfg(feature = "haematite-backend")]
712    cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
713    /// The peers the SS-5b supervisor watches, each with the shards this node
714    /// adopts on its death. Empty for non-distributed boots.
715    #[cfg(feature = "haematite-backend")]
716    watched_peers: Vec<crate::cluster::WatchedPeer>,
717    /// The static shard-directory peer entries (name + declared shards + gRPC
718    /// forward address) used to build the request-routing directory (R-2). Empty
719    /// for non-distributed boots.
720    #[cfg(feature = "haematite-backend")]
721    directory_peers: Vec<crate::routing::DirectoryPeer>,
722    /// This node's own distribution name (cluster `node_id`), so the SS-3
723    /// directory can resolve a shard-owner record naming THIS node to `Local`.
724    /// `None` for non-distributed boots.
725    #[cfg(feature = "haematite-backend")]
726    self_node_id: Option<String>,
727}
728
729impl ConnectedStore {
730    /// A non-distributed connected store: owns the coordinator's shard (so it
731    /// bootstraps the coordinator) and has no cluster responder.
732    fn local(event_store: Arc<dyn EventStore>, outbox_store: Option<Arc<dyn OutboxStore>>) -> Self {
733        Self {
734            event_store,
735            outbox_store,
736            bootstrap_coordinator: true,
737            #[cfg(feature = "haematite-backend")]
738            cluster_responder: None,
739            #[cfg(feature = "haematite-backend")]
740            cluster_store: None,
741            #[cfg(feature = "haematite-backend")]
742            watched_peers: Vec::new(),
743            #[cfg(feature = "haematite-backend")]
744            directory_peers: Vec::new(),
745            #[cfg(feature = "haematite-backend")]
746            self_node_id: None,
747        }
748    }
749}
750
751/// Connect the durable store, yielding the engine's [`EventStore`] handle and,
752/// for the libSQL backend, the SAME leaf store cast as an [`OutboxStore`].
753///
754/// Both handles are clones of one `Arc<LibSqlStore>`, which holds a single
755/// `libsql::Connection`. Sharing that connection with the outbox dispatcher
756/// serializes the engine's `append_with_outbox` and the dispatcher's
757/// `claim_outbox_rows` writes, so the two never contend across separate
758/// connections and never raise `SQLITE_BUSY`. The in-memory backend has no
759/// outbox table, so it yields `None`.
760async fn connect_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
761    match config.backend {
762        StoreBackend::Memory => Ok(ConnectedStore::local(
763            Arc::new(aion_store::InMemoryStore::default()),
764            None,
765        )),
766        StoreBackend::LibSql => {
767            let Some(url) = config.url else {
768                return Err(ServerError::Config {
769                    message: "store.url must not be empty when store.backend is libsql".to_owned(),
770                });
771            };
772            let store = LibSqlStore::open(url.clone())
773                .await
774                .map_err(ServerError::from)?;
775            store
776                .validate_event_compatibility()
777                .await
778                .map_err(|error| match error {
779                    aion_store::StoreError::Serialization(_) => ServerError::Config {
780                        message: format!(
781                            "Database schema mismatch — delete {url} and restart, or run migrations."
782                        ),
783                    },
784                    other => ServerError::from(other),
785                })?;
786            let leaf = Arc::new(store);
787            let event_store: Arc<dyn EventStore> = leaf.clone();
788            let outbox_store: Arc<dyn OutboxStore> = leaf;
789            Ok(ConnectedStore::local(event_store, Some(outbox_store)))
790        }
791        StoreBackend::Haematite => {
792            #[cfg(feature = "haematite-backend")]
793            {
794                connect_haematite_store(config).await
795            }
796            #[cfg(not(feature = "haematite-backend"))]
797            {
798                let _ = config;
799                connect_haematite_store_unavailable()
800            }
801        }
802    }
803}
804
805/// Connect the haematite backend, opening the on-disk database if `store.data_dir`
806/// already holds one and otherwise creating it with `store.shard_count` shards.
807///
808/// Without a `[store.cluster]` section this is the SINGLE-NODE path
809/// ([`HaematiteStore::open`] / [`create_with_shard_count`]), byte-identical to
810/// before: no endpoint, no election, owns everything, bootstraps the coordinator.
811/// With a cluster section this is the DISTRIBUTED path
812/// ([`HaematiteStore::open_or_create_distributed`]): it binds the replication
813/// endpoint, builds the quorum membership, dials peers, starts the responder, and
814/// computes whether THIS node owns the schedule-coordinator's shard so the engine
815/// boot path seeds the coordinator on exactly one owner cluster-wide (SS-2).
816///
817/// The SAME leaf `Arc<HaematiteStore>` is shared as both the engine's
818/// [`EventStore`] and the dispatcher's [`OutboxStore`] (one inner haematite
819/// database), mirroring the libSQL backend.
820///
821/// [`HaematiteStore::open`]: aion_store_haematite::HaematiteStore::open
822/// [`create_with_shard_count`]: aion_store_haematite::HaematiteStore::create_with_shard_count
823/// [`HaematiteStore::open_or_create_distributed`]: aion_store_haematite::HaematiteStore::open_or_create_distributed
824#[cfg(feature = "haematite-backend")]
825async fn connect_haematite_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
826    let Some(data_dir) = config.data_dir else {
827        return Err(ServerError::Config {
828            message: "store.data_dir must not be empty when store.backend is haematite".to_owned(),
829        });
830    };
831    let shard_count = config.shard_count;
832    let owned_shards = config.owned_shards.clone();
833    let cluster = config.cluster.clone();
834    // The peers the SS-5b supervisor watches, captured before `cluster` is moved
835    // into the blocking build. A peer with declared `owned_shards` becomes a
836    // watch target; peers without are kept out of the watch set (the supervisor
837    // would have nothing to adopt for them).
838    let watched_peers: Vec<crate::cluster::WatchedPeer> = cluster
839        .as_ref()
840        .map(|cluster| {
841            cluster
842                .peers
843                .iter()
844                .map(|peer| crate::cluster::WatchedPeer {
845                    name: peer.name.clone(),
846                    owned_shards: peer.owned_shards.clone(),
847                })
848                .collect()
849        })
850        .unwrap_or_default();
851    // The static shard-directory entries (R-2): each peer's declared shards plus
852    // its gRPC forward address. Built from the same config the supervisor uses.
853    let directory_peers: Vec<crate::routing::DirectoryPeer> = cluster
854        .as_ref()
855        .map(|cluster| {
856            cluster
857                .peers
858                .iter()
859                .map(|peer| crate::routing::DirectoryPeer {
860                    name: peer.name.clone(),
861                    owned_shards: peer.owned_shards.clone(),
862                    grpc_addr: peer.grpc_address,
863                })
864                .collect()
865        })
866        .unwrap_or_default();
867    // This node's own distribution name, so the SS-3 directory resolves a
868    // shard-owner record naming THIS node to `Local`.
869    let self_node_id: Option<String> = cluster.as_ref().map(|cluster| cluster.node_id.clone());
870    // Construction (and, for the distributed path, the off-runtime endpoint bind)
871    // must not stall the async runtime, so run it on the blocking pool. The
872    // distributed constructor itself steps onto a bare thread for the bind.
873    let (store, responder) =
874        tokio::task::spawn_blocking(move || build_haematite_store(&data_dir, shard_count, cluster))
875            .await
876            .map_err(|error| ServerError::Config {
877                message: format!("haematite store initialization task failed: {error}"),
878            })??;
879
880    // Gate the coordinator bootstrap on real ownership: a distributed node that
881    // does NOT own the coordinator's shard must not seed/fence it (AA-4-4). A
882    // single-node boot owns all shards, so it always bootstraps.
883    let bootstrap_coordinator = if owned_shards.is_empty() {
884        true
885    } else {
886        store.set_owned_shards(owned_shards.iter().copied());
887        store.owns_workflow_shard(&aion::schedule_coordinator_workflow_id())
888    };
889
890    let leaf = Arc::new(store);
891    let event_store: Arc<dyn EventStore> = leaf.clone();
892    let outbox_store: Arc<dyn OutboxStore> = leaf.clone();
893    // Retain the concrete store ONLY for a distributed boot (responder present),
894    // where the SS-5b supervisor will poll it for peer liveness. A single-node
895    // boot has no peers, so it carries no cluster store and never supervises.
896    let cluster_store = responder.as_ref().map(|_| leaf);
897    let (watched_peers, directory_peers, self_node_id) = if cluster_store.is_some() {
898        (watched_peers, directory_peers, self_node_id)
899    } else {
900        (Vec::new(), Vec::new(), None)
901    };
902    Ok(ConnectedStore {
903        event_store,
904        outbox_store: Some(outbox_store),
905        bootstrap_coordinator,
906        cluster_responder: responder,
907        cluster_store,
908        watched_peers,
909        directory_peers,
910        self_node_id,
911    })
912}
913
914/// Build the haematite store: the distributed path when a cluster section is
915/// present, otherwise the single-node path. Returns the store and (for the
916/// distributed path) its inbound-write responder. Restart-safe: an existing
917/// on-disk database is reused (its shard count wins) rather than re-created.
918#[cfg(feature = "haematite-backend")]
919fn build_haematite_store(
920    data_dir: &str,
921    shard_count: usize,
922    cluster: Option<crate::config::ClusterConfig>,
923) -> Result<
924    (
925        aion_store_haematite::HaematiteStore,
926        Option<aion_store_haematite::ClusterResponder>,
927    ),
928    ServerError,
929> {
930    use aion_store_haematite::{ClusterBootstrap, HaematiteStore};
931
932    let Some(cluster) = cluster else {
933        // Single-node path: byte-identical to before.
934        let path = std::path::Path::new(data_dir);
935        let store = if path.join("config.json").exists() {
936            HaematiteStore::open(path).map_err(ServerError::from)?
937        } else {
938            HaematiteStore::create_with_shard_count(path, shard_count).map_err(ServerError::from)?
939        };
940        return Ok((store, None));
941    };
942
943    let boot = ClusterBootstrap {
944        node_id: cluster.node_id,
945        bind_address: cluster.bind_address,
946        members: cluster.members,
947        peers: cluster
948            .peers
949            .into_iter()
950            .map(|peer| (peer.name, peer.address))
951            .collect(),
952        timeout: HAEMATITE_CLUSTER_OP_TIMEOUT,
953    };
954    let (store, responder) =
955        HaematiteStore::open_or_create_distributed(data_dir, shard_count, boot)
956            .map_err(ServerError::from)?;
957    Ok((store, Some(responder)))
958}
959
960/// Per-operation quorum/election timeout for the distributed haematite backend.
961#[cfg(feature = "haematite-backend")]
962const HAEMATITE_CLUSTER_OP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
963
964/// Reject `backend = haematite` cleanly when the optional `haematite-backend`
965/// feature is not compiled in, so a default build gives a precise operator
966/// error instead of a silent fallthrough.
967#[cfg(not(feature = "haematite-backend"))]
968fn connect_haematite_store_unavailable() -> Result<ConnectedStore, ServerError> {
969    Err(ServerError::Config {
970        message: "store.backend = haematite requires the aion-server `haematite-backend` feature"
971            .to_owned(),
972    })
973}
974
975#[cfg(test)]
976mod tests {
977    use std::{net::SocketAddr, time::Duration};
978
979    use aion_store::InMemoryStore;
980
981    use super::ServerState;
982    use crate::config::{
983        AuthConfig, AuthoringConfig, DashboardAssetSource, DashboardConfig, DeployConfig,
984        DevConfig, ListenConfig, MetricsConfig, NamespaceConfig, NamespaceMode, OutboxConfig,
985        RuntimeConfig, WebSocketConfig, WorkerConfig,
986    };
987
988    fn runtime_config() -> RuntimeConfig {
989        RuntimeConfig {
990            listen: ListenConfig {
991                grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
992                http: SocketAddr::from(([127, 0, 0, 1], 8080)),
993            },
994            tls: None,
995            auth: AuthConfig {
996                enabled: false,
997                jwks_url: None,
998                jwks_refresh_seconds: 300,
999            },
1000            dashboard: DashboardConfig {
1001                source: DashboardAssetSource::Embedded,
1002            },
1003            namespace: NamespaceConfig {
1004                mode: NamespaceMode::SharedEngine,
1005            },
1006            worker: WorkerConfig {
1007                heartbeat_window: Duration::from_millis(30_000),
1008            },
1009            websocket: WebSocketConfig {
1010                outbound_buffer_bound: 32,
1011                event_broadcast_capacity: Some(64),
1012            },
1013            workflow_packages: Vec::new(),
1014            deploy: DeployConfig::default(),
1015            authoring: AuthoringConfig::default(),
1016            dev: DevConfig::default(),
1017            outbox: OutboxConfig::default(),
1018            scheduler_threads: 1,
1019            query_timeout: Some(Duration::from_millis(10_000)),
1020            default_namespace: "default".to_owned(),
1021            drain_timeout: Duration::from_secs(30),
1022            metrics: MetricsConfig { enabled: true },
1023            owned_shards: Vec::new(),
1024            cors_allowed_origins: Vec::new(),
1025        }
1026    }
1027
1028    #[tokio::test]
1029    async fn builds_state_with_in_memory_store() -> Result<(), Box<dyn std::error::Error>> {
1030        let state =
1031            ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
1032
1033        std::hint::black_box(state.namespace_guard());
1034        std::hint::black_box(state.worker_registry());
1035
1036        Ok(())
1037    }
1038
1039    #[cfg(feature = "haematite-backend")]
1040    #[tokio::test(flavor = "multi_thread")]
1041    async fn connect_store_haematite_round_trips_through_event_store()
1042    -> Result<(), Box<dyn std::error::Error>> {
1043        use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
1044        use aion_store::WriteToken;
1045        use chrono::Utc;
1046
1047        use crate::config::{StoreBackend, StoreConfig};
1048
1049        let data_dir = tempfile::tempdir()?;
1050        // Single shard, a fresh temp data_dir: the production connect path opens
1051        // an existing haematite database or creates one, then shares the leaf as
1052        // both the engine EventStore and the dispatcher OutboxStore.
1053        let connected = super::connect_store(StoreConfig {
1054            backend: StoreBackend::Haematite,
1055            url: None,
1056            owned_shards: Vec::new(),
1057            data_dir: Some(data_dir.path().to_string_lossy().into_owned()),
1058            shard_count: 1,
1059            cluster: None,
1060        })
1061        .await?;
1062        let event_store = connected.event_store;
1063        assert!(
1064            connected.outbox_store.is_some(),
1065            "the haematite backend shares its leaf store as the dispatcher's outbox store"
1066        );
1067        assert!(
1068            connected.bootstrap_coordinator,
1069            "a single-node haematite boot owns all shards and bootstraps the coordinator"
1070        );
1071        assert!(
1072            connected.cluster_responder.is_none(),
1073            "a single-node (no [cluster]) haematite boot has no distributed responder"
1074        );
1075
1076        let workflow_id = WorkflowId::new_v4();
1077        let event = aion_core::Event::WorkflowStarted {
1078            envelope: EventEnvelope {
1079                seq: 1,
1080                recorded_at: Utc::now(),
1081                workflow_id: workflow_id.clone(),
1082            },
1083            workflow_type: String::from("checkout"),
1084            input: Payload::new(ContentType::Json, b"{}".to_vec()),
1085            run_id: RunId::new_v4(),
1086            parent_run_id: None,
1087            package_version: PackageVersion::new("a".repeat(64)),
1088        };
1089        event_store
1090            .append(
1091                WriteToken::recorder(),
1092                &workflow_id,
1093                std::slice::from_ref(&event),
1094                0,
1095            )
1096            .await?;
1097        let history = event_store.read_history(&workflow_id).await?;
1098        assert_eq!(
1099            history.len(),
1100            1,
1101            "an event appended through the server's dyn EventStore reads back"
1102        );
1103        Ok(())
1104    }
1105
1106    #[tokio::test]
1107    async fn connect_store_shares_outbox_store_only_for_libsql()
1108    -> Result<(), Box<dyn std::error::Error>> {
1109        use crate::config::{StoreBackend, StoreConfig};
1110
1111        // Memory backend: no durable outbox table, so no outbox store handle —
1112        // and `outbox.enabled` over memory is rejected at dispatcher commission.
1113        let connected = super::connect_store(StoreConfig {
1114            backend: StoreBackend::Memory,
1115            url: None,
1116            owned_shards: Vec::new(),
1117            data_dir: None,
1118            shard_count: 1,
1119            cluster: None,
1120        })
1121        .await?;
1122        assert!(
1123            connected.outbox_store.is_none(),
1124            "the in-memory backend exposes no outbox store"
1125        );
1126
1127        // LibSql backend: the leaf Arc<LibSqlStore> is shared as BOTH the engine's
1128        // EventStore and the dispatcher's OutboxStore (one libsql::Connection), so
1129        // the dispatcher reuses the engine's connection rather than opening a
1130        // second contending one (the inc-8 contention fix).
1131        let path = std::env::temp_dir().join(format!(
1132            "aion-connect-store-{}-{}.db",
1133            std::process::id(),
1134            std::time::SystemTime::now()
1135                .duration_since(std::time::UNIX_EPOCH)
1136                .map(|elapsed| elapsed.as_nanos())
1137                .unwrap_or_default()
1138        ));
1139        let connected = super::connect_store(StoreConfig {
1140            backend: StoreBackend::LibSql,
1141            url: Some(path.to_string_lossy().into_owned()),
1142            owned_shards: Vec::new(),
1143            data_dir: None,
1144            shard_count: 1,
1145            cluster: None,
1146        })
1147        .await?;
1148        assert!(
1149            connected.outbox_store.is_some(),
1150            "the libSQL backend shares its leaf store as the dispatcher's outbox store"
1151        );
1152        Ok(())
1153    }
1154
1155    #[tokio::test]
1156    async fn state_build_fails_without_event_broadcast_capacity()
1157    -> Result<(), Box<dyn std::error::Error>> {
1158        let mut runtime = runtime_config();
1159        runtime.websocket.event_broadcast_capacity = None;
1160
1161        let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
1162            .await
1163            .err()
1164            .ok_or("state build must fail when event streaming is unsized")?;
1165
1166        assert!(error.is_config(), "expected a config error, got {error}");
1167        assert!(
1168            error
1169                .to_string()
1170                .contains("websocket.event_broadcast_capacity"),
1171            "error must name the missing key: {error}"
1172        );
1173        Ok(())
1174    }
1175
1176    #[tokio::test]
1177    async fn state_build_fails_without_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
1178        let mut runtime = runtime_config();
1179        runtime.query_timeout = None;
1180
1181        let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
1182            .await
1183            .err()
1184            .ok_or("state build must fail when the query reply deadline is unset")?;
1185
1186        assert!(error.is_config(), "expected a config error, got {error}");
1187        assert!(
1188            error.to_string().contains("runtime.query_timeout_ms"),
1189            "error must name the missing key: {error}"
1190        );
1191        assert!(
1192            error.to_string().contains("AION_RUNTIME_QUERY_TIMEOUT_MS"),
1193            "error must name the environment override: {error}"
1194        );
1195        Ok(())
1196    }
1197
1198    #[tokio::test]
1199    async fn state_build_fails_with_zero_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
1200        let mut runtime = runtime_config();
1201        runtime.query_timeout = Some(Duration::ZERO);
1202
1203        let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
1204            .await
1205            .err()
1206            .ok_or("state build must fail when the query reply deadline is zero")?;
1207
1208        assert!(error.is_config(), "expected a config error, got {error}");
1209        assert!(
1210            error.to_string().contains("runtime.query_timeout_ms"),
1211            "error must name the zero-valued key: {error}"
1212        );
1213        Ok(())
1214    }
1215}