1use 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#[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 activity_mock_registry: Option<ActivityMockRegistry>,
47 outbox_store: Option<Arc<dyn OutboxStore>>,
51 outbox_wake: Arc<tokio::sync::Notify>,
58 #[cfg(feature = "haematite-backend")]
63 cluster_responder: Option<aion_store_haematite::ClusterResponder>,
64 #[cfg(feature = "haematite-backend")]
67 cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
68 #[cfg(feature = "haematite-backend")]
71 watched_peers: Vec<crate::cluster::WatchedPeer>,
72 #[cfg(feature = "haematite-backend")]
77 shard_directory: Option<Arc<crate::routing::StaticShardDirectory>>,
78 #[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 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 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 #[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 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 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 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 #[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 #[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 #[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 #[must_use]
346 pub fn namespace_guard(&self) -> &NamespaceGuard {
347 &self.inner.namespace_guard
348 }
349
350 #[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 #[must_use]
358 pub fn runtime_config(&self) -> &RuntimeConfig {
359 &self.inner.runtime
360 }
361
362 #[must_use]
364 pub fn worker_registry(&self) -> &ConnectedWorkerRegistry {
365 &self.inner.worker_registry
366 }
367
368 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 #[must_use]
390 pub fn pending_activities(&self) -> &PendingActivities {
391 &self.inner.pending_activities
392 }
393
394 #[must_use]
396 pub fn heartbeat_tracker(&self) -> &HeartbeatTracker {
397 &self.inner.heartbeat_tracker
398 }
399
400 #[must_use]
402 pub fn drain_state(&self) -> &DrainState {
403 &self.inner.drain_state
404 }
405
406 #[must_use]
408 pub fn metrics(&self) -> Option<&Metrics> {
409 self.inner.metrics.as_ref()
410 }
411
412 #[must_use]
414 pub fn health(&self) -> Option<&HealthState> {
415 self.inner.health.as_ref()
416 }
417
418 #[must_use]
423 pub fn activity_mock_registry(&self) -> Option<&ActivityMockRegistry> {
424 self.inner.activity_mock_registry.as_ref()
425 }
426
427 #[must_use]
433 pub fn outbox_store(&self) -> Option<Arc<dyn OutboxStore>> {
434 self.inner.outbox_store.clone()
435 }
436
437 #[must_use]
443 pub fn outbox_wake(&self) -> Arc<tokio::sync::Notify> {
444 Arc::clone(&self.inner.outbox_wake)
445 }
446
447 #[cfg(feature = "haematite-backend")]
453 #[must_use]
454 pub fn is_clustered(&self) -> bool {
455 self.inner.cluster_responder.is_some()
456 }
457
458 #[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 #[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 #[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 #[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 #[cfg(feature = "auth")]
528 #[must_use]
529 pub fn jwks_cache(&self) -> Option<&JwksCache> {
530 self.inner.jwks_cache.as_ref()
531 }
532
533 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
569struct EngineAssembly<'a> {
571 instrumented_store: &'a Arc<InstrumentedEventStore>,
573 event_broadcast_capacity: std::num::NonZeroUsize,
575 query_timeout: std::time::Duration,
577 activity_dispatcher: Arc<dyn ActivityDispatcher>,
579 active_registry: Arc<aion::Registry>,
581 bootstrap_coordinator: bool,
583 runtime: &'a RuntimeConfig,
585}
586
587async 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 .bootstrap_schedule_coordinator(assembly.bootstrap_coordinator)
624 .load_workflow_sources(runtime.workflow_packages.iter().map(PathBuf::as_path));
625 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
638fn 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#[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#[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
693struct 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 #[cfg(feature = "haematite-backend")]
712 cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
713 #[cfg(feature = "haematite-backend")]
716 watched_peers: Vec<crate::cluster::WatchedPeer>,
717 #[cfg(feature = "haematite-backend")]
721 directory_peers: Vec<crate::routing::DirectoryPeer>,
722 #[cfg(feature = "haematite-backend")]
726 self_node_id: Option<String>,
727}
728
729impl ConnectedStore {
730 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
751async 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#[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 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 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 let self_node_id: Option<String> = cluster.as_ref().map(|cluster| cluster.node_id.clone());
870 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 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 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#[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 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#[cfg(feature = "haematite-backend")]
962const HAEMATITE_CLUSTER_OP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
963
964#[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 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 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 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}