1use std::{path::PathBuf, sync::Arc};
4
5use aion::{
6 ActivityDispatcher, EngineBuilder, RuntimeHandle, SignalRouter, signal::ConcreteSignalRouter,
7};
8use aion_store::{EventStore, NamespaceStore, OutboxStore};
9#[cfg(feature = "libsql-backend")]
10use aion_store_libsql::LibSqlStore;
11
12use crate::dev_ui::{ActivityMockRegistry, DevMockingDispatcher};
13
14#[cfg(feature = "auth")]
15use crate::auth::JwksCache;
16use crate::{
17 config::{RuntimeConfig, ServerConfig, StoreBackend, StoreConfig},
18 error::ServerError,
19 namespace::{NamespaceGuard, NamespaceMinter, resolver::NamespaceResolver},
20 observability::{
21 Metrics, health::HealthState, instrumented_store::InstrumentedEventStore,
22 metrics::MetricsError,
23 },
24 shutdown::DrainState,
25 worker::{
26 ConnectedWorkerRegistry, HeartbeatTracker, PendingActivities, WorkerActivityDispatcher,
27 },
28};
29
30#[derive(Clone)]
32pub struct ServerState {
33 inner: Arc<ServerStateInner>,
34}
35
36struct ServerStateInner {
37 namespace_guard: NamespaceGuard,
38 runtime: RuntimeConfig,
39 worker_registry: ConnectedWorkerRegistry,
40 pending_activities: PendingActivities,
41 heartbeat_tracker: HeartbeatTracker,
42 drain_state: DrainState,
43 metrics: Option<Metrics>,
44 health: Option<HealthState>,
45 activity_mock_registry: Option<ActivityMockRegistry>,
48 outbox_store: Option<Arc<dyn OutboxStore>>,
52 namespace_store: Arc<dyn NamespaceStore>,
61 outbox_wake: Arc<tokio::sync::Notify>,
68 cluster_publisher: crate::cluster_publisher::ClusterEventPublisher,
72 transcript_publisher: crate::activity_publisher::ActivityEventPublisher,
81 attempt_owners: crate::worker::AttemptOwnerIndex,
88 queue_service_state: crate::worker::QueueServiceState,
94 queue_declarations: crate::worker::QueueDeclarationSource,
98 workspace_root: crate::worker::WorkspaceRoot,
106 cluster_self_node: Option<String>,
111 #[cfg(feature = "haematite-backend")]
116 cluster_responder: Option<aion_store_haematite::ClusterResponder>,
117 #[cfg(feature = "haematite-backend")]
120 cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
121 #[cfg(feature = "haematite-backend")]
124 watched_peers: Vec<crate::cluster::WatchedPeer>,
125 #[cfg(feature = "haematite-backend")]
130 shard_directory: Option<Arc<crate::routing::StaticShardDirectory>>,
131 #[cfg(feature = "haematite-backend")]
135 request_forwarder: Option<Arc<dyn crate::routing::RequestForwarder>>,
136 #[cfg(feature = "auth")]
137 jwks_cache: Option<JwksCache>,
138}
139
140impl ServerState {
141 const FALLBACK_CLUSTER_BROADCAST_CAPACITY: std::num::NonZeroUsize =
150 match std::num::NonZeroUsize::new(64) {
151 Some(value) => value,
152 None => std::num::NonZeroUsize::MIN,
153 };
154
155 pub async fn build(config: ServerConfig) -> Result<Self, ServerError> {
162 let (store_config, runtime) = config.into_parts();
163 let connected = connect_store(store_config).await?;
164 Self::build_with_connected_store(connected, runtime).await
165 }
166
167 pub async fn build_with_store<S>(store: S, runtime: RuntimeConfig) -> Result<Self, ServerError>
173 where
174 S: EventStore + NamespaceStore,
175 {
176 let leaf = Arc::new(store);
180 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
181 Self::build_with_connected_store(
182 ConnectedStore::local(leaf, None, namespace_store),
183 runtime,
184 )
185 .await
186 }
187
188 async fn build_with_connected_store(
189 connected: ConnectedStore,
190 runtime: RuntimeConfig,
191 ) -> Result<Self, ServerError> {
192 let outbox_store = connected.outbox_store;
193 let bootstrap_coordinator = connected.bootstrap_coordinator;
194 #[cfg(feature = "haematite-backend")]
195 let cluster_responder = connected.cluster_responder;
196 #[cfg(feature = "haematite-backend")]
197 let cluster_store = connected.cluster_store;
198 #[cfg(feature = "haematite-backend")]
199 let watched_peers = connected.watched_peers;
200 #[cfg(feature = "haematite-backend")]
203 let cluster_self_node = connected.self_node_id.clone();
204 #[cfg(not(feature = "haematite-backend"))]
205 let cluster_self_node: Option<String> = None;
206 #[cfg(feature = "haematite-backend")]
210 let RoutingState {
211 shard_directory,
212 request_forwarder,
213 mint_routing,
214 } = build_routing_state(
215 cluster_store.as_ref(),
216 connected.directory_peers,
217 connected.self_node_id,
218 );
219 let (event_broadcast_capacity, query_timeout) = required_engine_seams(&runtime)?;
220 let (cluster_publisher, transcript_publisher) =
221 build_real_time_publishers(&runtime, connected.observability_store)?;
222 let metrics = Metrics::new().map_err(|error| metrics_config_error(&error))?;
223 let outbox_wake = Arc::new(tokio::sync::Notify::new());
228 let instrumented_store = Arc::new(
229 InstrumentedEventStore::new(
230 connected.event_store,
231 metrics.clone(),
232 runtime.default_namespace.clone(),
233 )
234 .with_outbox_wake(Arc::clone(&outbox_wake)),
235 );
236 let exported_metrics = runtime.metrics.enabled.then_some(metrics.clone());
237 #[cfg(not(feature = "haematite-backend"))]
238 let mint_routing: Option<crate::namespace::NamespaceRouting> = None;
239 let seams = build_worker_seams(
240 &runtime,
241 &cluster_publisher,
242 &connected.namespace_store,
243 mint_routing,
244 );
245 let workspace_root = crate::worker::WorkspaceRoot::resolve();
249 let (activity_dispatcher, activity_mock_registry, attempt_owners) =
250 build_decorated_dispatcher(&runtime, &seams, workspace_root.clone());
251
252 let engine = build_engine(EngineAssembly {
253 instrumented_store: &instrumented_store,
254 event_broadcast_capacity,
255 query_timeout,
256 activity_dispatcher,
257 active_registry: Arc::new(aion::Registry::default()),
258 bootstrap_coordinator,
259 runtime: &runtime,
260 })
261 .await?;
262 let engine = Arc::new(engine);
263 install_engine_backed_seams(&seams, &engine, runtime.outbox.enabled);
264 let resolver = NamespaceResolver::from_config(runtime.namespace.clone(), engine);
265 #[cfg(feature = "auth")]
266 let jwks_cache = build_jwks_cache(&runtime).await?;
267 Ok(Self {
268 inner: Arc::new(ServerStateInner {
269 namespace_guard: NamespaceGuard::new(resolver),
270 runtime,
271 worker_registry: seams.worker_registry,
272 pending_activities: seams.pending_activities,
273 heartbeat_tracker: seams.heartbeat_tracker,
274 drain_state: seams.drain_state,
275 metrics: exported_metrics,
276 health: Some(HealthState::new(instrumented_store, true)),
277 activity_mock_registry,
278 outbox_store,
279 namespace_store: connected.namespace_store,
280 outbox_wake,
281 cluster_publisher,
282 transcript_publisher,
283 attempt_owners,
284 queue_service_state: seams.queue_service_state,
285 queue_declarations: seams.queue_declarations,
286 workspace_root,
287 cluster_self_node,
288 #[cfg(feature = "haematite-backend")]
289 cluster_responder,
290 #[cfg(feature = "haematite-backend")]
291 cluster_store,
292 #[cfg(feature = "haematite-backend")]
293 watched_peers,
294 #[cfg(feature = "haematite-backend")]
295 shard_directory,
296 #[cfg(feature = "haematite-backend")]
297 request_forwarder,
298 #[cfg(feature = "auth")]
299 jwks_cache,
300 }),
301 })
302 }
303
304 #[must_use]
306 pub fn from_parts(namespace_resolver: NamespaceResolver, runtime: RuntimeConfig) -> Self {
307 Self::from_parts_with_namespace_store(
312 namespace_resolver,
313 runtime,
314 Arc::new(aion_store::InMemoryStore::default()),
315 )
316 }
317
318 #[must_use]
326 pub fn from_parts_with_namespace_store(
327 namespace_resolver: NamespaceResolver,
328 runtime: RuntimeConfig,
329 namespace_store: Arc<dyn NamespaceStore>,
330 ) -> Self {
331 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
332 let pending_activities =
339 PendingActivities::default().with_heartbeat_window(runtime.worker.heartbeat_window);
340 let bounds = transcript_bounds(&runtime);
344 Self {
345 inner: Arc::new(ServerStateInner {
346 namespace_guard: NamespaceGuard::new(namespace_resolver),
347 runtime,
348 worker_registry: ConnectedWorkerRegistry::default(),
349 pending_activities,
350 heartbeat_tracker,
351 drain_state: DrainState::default(),
352 metrics: None,
353 health: None,
354 activity_mock_registry: None,
355 outbox_store: None,
356 namespace_store,
357 outbox_wake: Arc::new(tokio::sync::Notify::new()),
358 cluster_publisher: crate::cluster_publisher::ClusterEventPublisher::new(
359 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
360 ),
361 transcript_publisher: build_transcript_publisher(
365 None,
366 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
367 bounds,
368 ),
369 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
370 queue_service_state: crate::worker::QueueServiceState::default(),
371 queue_declarations: crate::worker::QueueDeclarationSource::default(),
372 workspace_root: crate::worker::WorkspaceRoot::resolve(),
376 cluster_self_node: None,
377 #[cfg(feature = "haematite-backend")]
378 cluster_responder: None,
379 #[cfg(feature = "haematite-backend")]
380 cluster_store: None,
381 #[cfg(feature = "haematite-backend")]
382 watched_peers: Vec::new(),
383 #[cfg(feature = "haematite-backend")]
384 shard_directory: None,
385 #[cfg(feature = "haematite-backend")]
386 request_forwarder: None,
387 #[cfg(feature = "auth")]
388 jwks_cache: None,
389 }),
390 }
391 }
392
393 #[cfg(feature = "auth")]
402 #[must_use]
403 pub fn from_parts_with_namespace_store_and_jwks(
404 namespace_resolver: NamespaceResolver,
405 runtime: RuntimeConfig,
406 namespace_store: Arc<dyn NamespaceStore>,
407 jwks_cache: JwksCache,
408 ) -> Self {
409 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
410 let pending_activities =
417 PendingActivities::default().with_heartbeat_window(runtime.worker.heartbeat_window);
418 let bounds = transcript_bounds(&runtime);
422 Self {
423 inner: Arc::new(ServerStateInner {
424 namespace_guard: NamespaceGuard::new(namespace_resolver),
425 runtime,
426 worker_registry: ConnectedWorkerRegistry::default(),
427 pending_activities,
428 heartbeat_tracker,
429 drain_state: DrainState::default(),
430 metrics: None,
431 health: None,
432 activity_mock_registry: None,
433 outbox_store: None,
434 namespace_store,
435 outbox_wake: Arc::new(tokio::sync::Notify::new()),
436 cluster_publisher: crate::cluster_publisher::ClusterEventPublisher::new(
437 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
438 ),
439 transcript_publisher: build_transcript_publisher(
443 None,
444 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
445 bounds,
446 ),
447 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
448 queue_service_state: crate::worker::QueueServiceState::default(),
449 queue_declarations: crate::worker::QueueDeclarationSource::default(),
450 workspace_root: crate::worker::WorkspaceRoot::resolve(),
454 cluster_self_node: None,
455 #[cfg(feature = "haematite-backend")]
456 cluster_responder: None,
457 #[cfg(feature = "haematite-backend")]
458 cluster_store: None,
459 #[cfg(feature = "haematite-backend")]
460 watched_peers: Vec::new(),
461 #[cfg(feature = "haematite-backend")]
462 shard_directory: None,
463 #[cfg(feature = "haematite-backend")]
464 request_forwarder: None,
465 jwks_cache: Some(jwks_cache),
466 }),
467 }
468 }
469
470 #[cfg(feature = "auth")]
476 #[must_use]
477 pub fn from_parts_with_jwks(
478 namespace_resolver: NamespaceResolver,
479 runtime: RuntimeConfig,
480 jwks_cache: JwksCache,
481 ) -> Self {
482 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
483 let pending_activities =
490 PendingActivities::default().with_heartbeat_window(runtime.worker.heartbeat_window);
491 let bounds = transcript_bounds(&runtime);
495 Self {
496 inner: Arc::new(ServerStateInner {
497 namespace_guard: NamespaceGuard::new(namespace_resolver),
498 runtime,
499 worker_registry: ConnectedWorkerRegistry::default(),
500 pending_activities,
501 heartbeat_tracker,
502 drain_state: DrainState::default(),
503 metrics: None,
504 health: None,
505 activity_mock_registry: None,
506 outbox_store: None,
507 namespace_store: Arc::new(aion_store::InMemoryStore::default()),
512 outbox_wake: Arc::new(tokio::sync::Notify::new()),
513 cluster_publisher: crate::cluster_publisher::ClusterEventPublisher::new(
514 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
515 ),
516 transcript_publisher: build_transcript_publisher(
520 None,
521 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
522 bounds,
523 ),
524 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
525 queue_service_state: crate::worker::QueueServiceState::default(),
526 queue_declarations: crate::worker::QueueDeclarationSource::default(),
527 workspace_root: crate::worker::WorkspaceRoot::resolve(),
531 cluster_self_node: None,
532 #[cfg(feature = "haematite-backend")]
533 cluster_responder: None,
534 #[cfg(feature = "haematite-backend")]
535 cluster_store: None,
536 #[cfg(feature = "haematite-backend")]
537 watched_peers: Vec::new(),
538 #[cfg(feature = "haematite-backend")]
539 shard_directory: None,
540 #[cfg(feature = "haematite-backend")]
541 request_forwarder: None,
542 jwks_cache: Some(jwks_cache),
543 }),
544 }
545 }
546
547 #[must_use]
549 pub fn from_parts_with_registry(
550 namespace_resolver: NamespaceResolver,
551 runtime: RuntimeConfig,
552 worker_registry: ConnectedWorkerRegistry,
553 ) -> Self {
554 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
555 let pending_activities =
562 PendingActivities::default().with_heartbeat_window(runtime.worker.heartbeat_window);
563 let bounds = transcript_bounds(&runtime);
567 Self {
568 inner: Arc::new(ServerStateInner {
569 namespace_guard: NamespaceGuard::new(namespace_resolver),
570 runtime,
571 worker_registry,
572 pending_activities,
573 heartbeat_tracker,
574 drain_state: DrainState::default(),
575 metrics: None,
576 health: None,
577 activity_mock_registry: None,
578 outbox_store: None,
579 namespace_store: Arc::new(aion_store::InMemoryStore::default()),
584 outbox_wake: Arc::new(tokio::sync::Notify::new()),
585 cluster_publisher: crate::cluster_publisher::ClusterEventPublisher::new(
586 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
587 ),
588 transcript_publisher: build_transcript_publisher(
592 None,
593 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
594 bounds,
595 ),
596 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
597 queue_service_state: crate::worker::QueueServiceState::default(),
598 queue_declarations: crate::worker::QueueDeclarationSource::default(),
599 workspace_root: crate::worker::WorkspaceRoot::resolve(),
603 cluster_self_node: None,
604 #[cfg(feature = "haematite-backend")]
605 cluster_responder: None,
606 #[cfg(feature = "haematite-backend")]
607 cluster_store: None,
608 #[cfg(feature = "haematite-backend")]
609 watched_peers: Vec::new(),
610 #[cfg(feature = "haematite-backend")]
611 shard_directory: None,
612 #[cfg(feature = "haematite-backend")]
613 request_forwarder: None,
614 #[cfg(feature = "auth")]
615 jwks_cache: None,
616 }),
617 }
618 }
619
620 #[must_use]
622 pub fn namespace_guard(&self) -> &NamespaceGuard {
623 &self.inner.namespace_guard
624 }
625
626 #[must_use]
628 pub fn deploy_guard(&self) -> crate::deploy::DeployGuard {
629 crate::deploy::DeployGuard::new(self.inner.namespace_guard.resolver().clone())
630 }
631
632 #[must_use]
634 pub fn runtime_config(&self) -> &RuntimeConfig {
635 &self.inner.runtime
636 }
637
638 #[must_use]
648 pub fn workspace_root(&self) -> &crate::worker::WorkspaceRoot {
649 &self.inner.workspace_root
650 }
651
652 #[must_use]
654 pub fn worker_registry(&self) -> &ConnectedWorkerRegistry {
655 &self.inner.worker_registry
656 }
657
658 #[must_use]
662 pub fn cluster_publisher(&self) -> &crate::cluster_publisher::ClusterEventPublisher {
663 &self.inner.cluster_publisher
664 }
665
666 #[must_use]
671 pub fn transcript_publisher(&self) -> &crate::activity_publisher::ActivityEventPublisher {
672 &self.inner.transcript_publisher
673 }
674
675 #[must_use]
679 pub fn attempt_owners(&self) -> &crate::worker::AttemptOwnerIndex {
680 &self.inner.attempt_owners
681 }
682
683 #[must_use]
686 pub fn queue_service_state(&self) -> &crate::worker::QueueServiceState {
687 &self.inner.queue_service_state
688 }
689
690 #[must_use]
694 pub fn queue_declarations(&self) -> &crate::worker::QueueDeclarationSource {
695 &self.inner.queue_declarations
696 }
697
698 pub fn unserved_queues(&self) -> Result<Vec<crate::worker::UnservedQueue>, ServerError> {
709 self.inner.queue_service_state.unserved()
710 }
711
712 pub fn unrecoverable_runs(
730 &self,
731 ) -> Result<Vec<(aion_core::WorkflowId, aion::registry::UnrecoverableRun)>, ServerError> {
732 self.engine()?
733 .registry()
734 .unrecoverable()
735 .list()
736 .map_err(ServerError::from)
737 }
738
739 #[must_use]
751 pub fn intervention_router(&self) -> crate::worker::InterventionRouter {
752 let transport: std::sync::Arc<dyn crate::worker::InterventionTransport> = {
753 #[cfg(feature = "liminal-transport")]
754 {
755 std::sync::Arc::new(crate::worker::LiminalInterventionTransport)
756 }
757 #[cfg(not(feature = "liminal-transport"))]
758 {
759 std::sync::Arc::new(NullInterventionTransport)
760 }
761 };
762 crate::worker::InterventionRouter::new(
763 self.inner.worker_registry.clone(),
764 self.inner.attempt_owners.clone(),
765 transport,
766 )
767 .with_transcript_publisher(self.inner.transcript_publisher.clone())
770 }
771
772 #[must_use]
776 pub fn cluster_self_node(&self) -> Option<&str> {
777 self.inner.cluster_self_node.as_deref()
778 }
779
780 pub fn engine(&self) -> Result<Arc<aion::Engine>, ServerError> {
793 self.inner
794 .namespace_guard
795 .resolver()
796 .engine()
797 .map(Arc::clone)
798 }
799
800 #[must_use]
802 pub fn pending_activities(&self) -> &PendingActivities {
803 &self.inner.pending_activities
804 }
805
806 #[must_use]
808 pub fn heartbeat_tracker(&self) -> &HeartbeatTracker {
809 &self.inner.heartbeat_tracker
810 }
811
812 #[must_use]
814 pub fn drain_state(&self) -> &DrainState {
815 &self.inner.drain_state
816 }
817
818 #[must_use]
820 pub fn metrics(&self) -> Option<&Metrics> {
821 self.inner.metrics.as_ref()
822 }
823
824 #[must_use]
826 pub fn health(&self) -> Option<&HealthState> {
827 self.inner.health.as_ref()
828 }
829
830 #[must_use]
835 pub fn activity_mock_registry(&self) -> Option<&ActivityMockRegistry> {
836 self.inner.activity_mock_registry.as_ref()
837 }
838
839 #[must_use]
845 pub fn outbox_store(&self) -> Option<Arc<dyn OutboxStore>> {
846 self.inner.outbox_store.clone()
847 }
848
849 #[must_use]
858 pub fn namespace_store(&self) -> &Arc<dyn NamespaceStore> {
859 &self.inner.namespace_store
860 }
861
862 #[must_use]
871 pub fn namespace_minter(&self) -> NamespaceMinter {
872 let minter = NamespaceMinter::new(
873 Arc::clone(&self.inner.namespace_store),
874 self.inner.runtime.auto_create,
875 )
876 .with_cluster_publisher(self.inner.cluster_publisher.clone());
881 match self.namespace_routing() {
886 Some(routing) => minter.with_routing(routing),
887 None => minter,
888 }
889 }
890
891 #[cfg(feature = "haematite-backend")]
901 #[must_use]
902 pub fn namespace_routing(&self) -> Option<crate::namespace::NamespaceRouting> {
903 build_namespace_routing(
904 self.cluster_store(),
905 self.shard_directory(),
906 self.request_forwarder(),
907 )
908 }
909
910 #[cfg(not(feature = "haematite-backend"))]
912 #[must_use]
913 pub const fn namespace_routing(&self) -> Option<crate::namespace::NamespaceRouting> {
914 None
915 }
916
917 #[must_use]
923 pub fn outbox_wake(&self) -> Arc<tokio::sync::Notify> {
924 Arc::clone(&self.inner.outbox_wake)
925 }
926
927 #[cfg(feature = "haematite-backend")]
933 #[must_use]
934 pub fn is_clustered(&self) -> bool {
935 self.inner.cluster_responder.is_some()
936 }
937
938 #[cfg(feature = "haematite-backend")]
943 #[must_use]
944 pub fn cluster_store(&self) -> Option<&Arc<aion_store_haematite::HaematiteStore>> {
945 self.inner.cluster_store.as_ref()
946 }
947
948 #[cfg(feature = "haematite-backend")]
952 #[must_use]
953 pub fn shard_directory(&self) -> Option<&Arc<crate::routing::StaticShardDirectory>> {
954 self.inner.shard_directory.as_ref()
955 }
956
957 #[cfg(feature = "haematite-backend")]
960 #[must_use]
961 pub fn request_forwarder(&self) -> Option<&Arc<dyn crate::routing::RequestForwarder>> {
962 self.inner.request_forwarder.as_ref()
963 }
964
965 #[must_use]
982 pub fn spawn_heartbeat_sweeper(
983 &self,
984 shutdown: tokio::sync::watch::Receiver<bool>,
985 ) -> tokio::task::JoinHandle<()> {
986 let sweeper = crate::worker::HeartbeatSweeper::new(
987 self.inner.heartbeat_tracker.clone(),
988 self.inner.worker_registry.clone(),
989 self.inner.pending_activities.clone(),
990 self.inner.drain_state.clone(),
991 self.inner.runtime.worker.heartbeat_window,
992 )
993 .with_queue_state(self.inner.queue_service_state.clone());
997 tokio::spawn(sweeper.run(shutdown))
998 }
999
1000 #[cfg(feature = "liminal-transport")]
1011 #[must_use]
1012 pub fn spawn_liminal_liveness_probe(
1013 &self,
1014 notifier: std::sync::Arc<crate::worker::LiminalConnectionNotifier>,
1015 shutdown: tokio::sync::watch::Receiver<bool>,
1016 ) -> tokio::task::JoinHandle<()> {
1017 let probe = crate::worker::LivenessProbe::new(
1018 notifier,
1019 self.inner.heartbeat_tracker.clone(),
1020 self.inner.worker_registry.clone(),
1021 self.inner.runtime.worker.heartbeat_window,
1022 );
1023 tokio::spawn(probe.run(shutdown))
1024 }
1025
1026 #[cfg(feature = "haematite-backend")]
1042 pub fn spawn_cluster_supervisor(
1043 &self,
1044 config: crate::cluster::SupervisorConfig,
1045 shutdown: tokio::sync::watch::Receiver<bool>,
1046 ) -> Result<bool, ServerError> {
1047 let Some(cluster_store) = self.inner.cluster_store.clone() else {
1048 return Ok(false);
1049 };
1050 if self.inner.watched_peers.is_empty() {
1051 return Ok(false);
1052 }
1053 let engine = Arc::clone(self.inner.namespace_guard.resolver().engine()?);
1054 let publisher = Arc::new(self.inner.cluster_publisher.clone());
1058 let self_node = self.inner.cluster_self_node.clone().unwrap_or_default();
1059 let adopter = Arc::new(crate::cluster::OutboxSettlingAdopter::new(
1065 engine,
1066 self.inner.outbox_store.clone(),
1067 ));
1068 let supervisor = crate::cluster::ClusterSupervisor::new(
1069 cluster_store,
1070 adopter,
1071 self.inner.watched_peers.clone(),
1072 config,
1073 )
1074 .with_publisher(publisher, self_node);
1075 if !supervisor.watches_any() {
1076 return Ok(false);
1077 }
1078 tokio::spawn(supervisor.run(shutdown));
1079 Ok(true)
1080 }
1081
1082 #[cfg(feature = "auth")]
1084 #[must_use]
1085 pub fn jwks_cache(&self) -> Option<&JwksCache> {
1086 self.inner.jwks_cache.as_ref()
1087 }
1088
1089 pub fn shutdown(&self) -> Result<(), ServerError> {
1096 self.inner.namespace_guard.resolver().shutdown_engine()
1097 }
1098}
1099
1100#[cfg(feature = "auth")]
1101async fn build_jwks_cache(runtime: &RuntimeConfig) -> Result<Option<JwksCache>, ServerError> {
1102 if !runtime.auth.enabled {
1103 return Ok(None);
1104 }
1105 let Some(url) = runtime.auth.jwks_url.clone() else {
1106 return Err(ServerError::Config {
1107 message: "auth.jwks_url must not be empty when auth.enabled is true".to_owned(),
1108 });
1109 };
1110 let interval = std::time::Duration::from_secs(runtime.auth.jwks_refresh_seconds);
1111 let cache = JwksCache::new(url, interval)
1112 .await
1113 .map_err(|error| ServerError::Config {
1114 message: format!("auth jwks initial fetch failed: {error}"),
1115 })?;
1116 Ok(Some(cache))
1117}
1118
1119fn metrics_config_error(error: &MetricsError) -> ServerError {
1120 ServerError::Config {
1121 message: error.to_string(),
1122 }
1123}
1124
1125struct EngineAssembly<'a> {
1127 instrumented_store: &'a Arc<InstrumentedEventStore>,
1129 event_broadcast_capacity: std::num::NonZeroUsize,
1131 query_timeout: std::time::Duration,
1133 activity_dispatcher: Arc<dyn ActivityDispatcher>,
1135 active_registry: Arc<aion::Registry>,
1137 bootstrap_coordinator: bool,
1139 runtime: &'a RuntimeConfig,
1141}
1142
1143async fn build_engine(assembly: EngineAssembly<'_>) -> Result<aion::Engine, ServerError> {
1150 let mut search_attribute_schema = aion_core::SearchAttributeSchema::new();
1151 search_attribute_schema
1152 .register(
1153 crate::namespace::NAMESPACE_ATTRIBUTE,
1154 aion_core::SearchAttributeType::String,
1155 )
1156 .map_err(|error| ServerError::Config {
1157 message: format!("failed to register namespace search attribute: {error}"),
1158 })?;
1159 search_attribute_schema
1160 .register(
1161 crate::namespace::TASK_QUEUE_ATTRIBUTE,
1162 aion_core::SearchAttributeType::String,
1163 )
1164 .map_err(|error| ServerError::Config {
1165 message: format!("failed to register task_queue search attribute: {error}"),
1166 })?;
1167 let runtime = assembly.runtime;
1168 let builder = EngineBuilder::new()
1169 .store_arc(assembly.instrumented_store.clone())
1170 .event_streaming(assembly.event_broadcast_capacity)
1171 .in_memory_visibility()
1172 .search_attribute_schema(search_attribute_schema)
1173 .scheduler_threads(runtime.scheduler_threads)
1174 .outbox_enabled(runtime.outbox.enabled)
1175 .activity_dispatcher(assembly.activity_dispatcher)
1176 .active_registry(assembly.active_registry)
1177 .production_recovery_seam()
1178 .signal_router_factory(|runtime: Arc<RuntimeHandle>, handoff| {
1179 Arc::new(ConcreteSignalRouter::new(runtime, handoff)) as Arc<dyn SignalRouter>
1180 })
1181 .query_timeout(assembly.query_timeout)
1182 .bootstrap_schedule_coordinator(assembly.bootstrap_coordinator)
1188 .load_workflow_sources(runtime.workflow_packages.iter().map(PathBuf::as_path));
1189 let builder = if runtime.owned_shards.is_empty() {
1195 builder
1196 } else {
1197 builder.owned_shards(runtime.owned_shards.iter().copied())
1198 };
1199 builder.build().await.map_err(ServerError::from)
1200}
1201
1202fn required_engine_seams(
1207 runtime: &RuntimeConfig,
1208) -> Result<(std::num::NonZeroUsize, std::time::Duration), ServerError> {
1209 let event_broadcast_capacity = runtime
1210 .websocket
1211 .event_broadcast_capacity
1212 .and_then(std::num::NonZeroUsize::new)
1213 .ok_or_else(|| ServerError::Config {
1214 message: crate::config::EVENT_BROADCAST_CAPACITY_REQUIRED.to_owned(),
1215 })?;
1216 let query_timeout = runtime
1217 .query_timeout
1218 .filter(|timeout| !timeout.is_zero())
1219 .ok_or_else(|| ServerError::Config {
1220 message: crate::config::QUERY_TIMEOUT_REQUIRED.to_owned(),
1221 })?;
1222 Ok((event_broadcast_capacity, query_timeout))
1223}
1224
1225fn install_outbox_delivery(
1232 pending_activities: &PendingActivities,
1233 engine: &Arc<aion::Engine>,
1234 outbox_enabled: bool,
1235) {
1236 if outbox_enabled {
1237 let callback = Arc::new(crate::worker::ServerOutboxDeliveryCallback::new(
1238 Arc::clone(engine),
1239 ));
1240 pending_activities.set_outbox_delivery(callback);
1241 }
1242}
1243
1244struct WorkerSeams {
1246 worker_registry: ConnectedWorkerRegistry,
1247 pending_activities: PendingActivities,
1248 heartbeat_tracker: HeartbeatTracker,
1249 drain_state: DrainState,
1250 queue_declarations: crate::worker::QueueDeclarationSource,
1251 queue_service_state: crate::worker::QueueServiceState,
1252 declared_bodies: crate::worker::DeclaredBodySource,
1253}
1254
1255fn build_worker_seams(
1260 runtime: &RuntimeConfig,
1261 cluster_publisher: &crate::cluster_publisher::ClusterEventPublisher,
1262 namespace_store: &Arc<dyn NamespaceStore>,
1263 mint_routing: Option<crate::namespace::NamespaceRouting>,
1264) -> WorkerSeams {
1265 let worker_registry = ConnectedWorkerRegistry::default()
1266 .with_cluster_publisher(cluster_publisher.clone())
1267 .with_namespace_minting(namespace_store.clone(), runtime.auto_create);
1268 let worker_registry = match mint_routing {
1274 Some(routing) => worker_registry.with_namespace_routing(routing),
1275 None => worker_registry,
1276 };
1277 WorkerSeams {
1278 worker_registry,
1279 pending_activities: PendingActivities::default()
1284 .with_heartbeat_window(runtime.worker.heartbeat_window),
1285 heartbeat_tracker: HeartbeatTracker::new(runtime.worker.heartbeat_window),
1286 drain_state: DrainState::default(),
1287 queue_declarations: crate::worker::QueueDeclarationSource::default(),
1288 queue_service_state: crate::worker::QueueServiceState::default(),
1289 declared_bodies: crate::worker::DeclaredBodySource::default(),
1290 }
1291}
1292
1293fn install_queue_declarations(
1300 queue_declarations: &crate::worker::QueueDeclarationSource,
1301 engine: &Arc<aion::Engine>,
1302) {
1303 queue_declarations.install(Arc::new(crate::worker::EngineQueueDeclarations::new(
1304 Arc::clone(engine),
1305 )));
1306}
1307
1308fn install_declared_bodies(
1316 declared_bodies: &crate::worker::DeclaredBodySource,
1317 engine: &Arc<aion::Engine>,
1318) {
1319 declared_bodies.install(Arc::new(crate::worker::EngineDeclaredBodies::new(
1320 Arc::clone(engine),
1321 )));
1322}
1323
1324fn install_engine_backed_seams(seams: &WorkerSeams, engine: &Arc<aion::Engine>, outbox: bool) {
1330 install_outbox_delivery(&seams.pending_activities, engine, outbox);
1331 install_queue_declarations(&seams.queue_declarations, engine);
1332 install_declared_bodies(&seams.declared_bodies, engine);
1333}
1334
1335fn build_bridge_dispatcher(
1348 runtime: &RuntimeConfig,
1349 seams: &WorkerSeams,
1350) -> (WorkerActivityDispatcher, crate::worker::AttemptOwnerIndex) {
1351 let attempt_owners = crate::worker::AttemptOwnerIndex::new();
1352 let dispatcher = WorkerActivityDispatcher::new(
1353 seams.worker_registry.clone(),
1354 runtime.default_namespace.clone(),
1355 seams.heartbeat_tracker.clone(),
1356 )
1357 .with_pending(seams.pending_activities.clone())
1358 .with_drain_state(seams.drain_state.clone())
1359 .with_tokio_handle(tokio::runtime::Handle::current())
1360 .with_attempt_owners(attempt_owners.clone())
1361 .with_queue_service(runtime.worker.queue_service.clone())
1362 .with_queue_declarations(seams.queue_declarations.clone())
1363 .with_queue_state(seams.queue_service_state.clone());
1364 (dispatcher, attempt_owners)
1365}
1366
1367fn build_decorated_dispatcher(
1370 runtime: &RuntimeConfig,
1371 seams: &WorkerSeams,
1372 workspace_root: crate::worker::WorkspaceRoot,
1373) -> (
1374 Arc<dyn ActivityDispatcher>,
1375 Option<ActivityMockRegistry>,
1376 crate::worker::AttemptOwnerIndex,
1377) {
1378 let (dispatcher, attempt_owners) = build_bridge_dispatcher(runtime, seams);
1379 let (activity_dispatcher, activity_mock_registry) = decorate_activity_dispatcher(
1380 dispatcher,
1381 seams.declared_bodies.clone(),
1382 workspace_root,
1383 runtime.dev.enabled,
1384 );
1385 (activity_dispatcher, activity_mock_registry, attempt_owners)
1386}
1387
1388fn decorate_activity_dispatcher(
1389 dispatcher: WorkerActivityDispatcher,
1390 declared_bodies: crate::worker::DeclaredBodySource,
1391 workspace_root: crate::worker::WorkspaceRoot,
1392 dev_enabled: bool,
1393) -> (Arc<dyn ActivityDispatcher>, Option<ActivityMockRegistry>) {
1394 let declared = crate::worker::DeclaredCommandDispatcher::new(
1400 Arc::new(dispatcher),
1401 declared_bodies,
1402 tokio::runtime::Handle::current(),
1403 workspace_root,
1404 );
1405 if dev_enabled {
1406 let registry = ActivityMockRegistry::new();
1407 let decorated = DevMockingDispatcher::new(Arc::new(declared), registry.clone());
1408 (Arc::new(decorated), Some(registry))
1409 } else {
1410 (Arc::new(declared), None)
1411 }
1412}
1413
1414fn required_cluster_broadcast_capacity(
1419 runtime: &RuntimeConfig,
1420) -> Result<std::num::NonZeroUsize, ServerError> {
1421 runtime
1422 .websocket
1423 .cluster_broadcast_capacity
1424 .and_then(std::num::NonZeroUsize::new)
1425 .ok_or_else(|| ServerError::Config {
1426 message: crate::config::CLUSTER_BROADCAST_CAPACITY_REQUIRED.to_owned(),
1427 })
1428}
1429
1430fn build_real_time_publishers(
1443 runtime: &RuntimeConfig,
1444 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1445) -> Result<
1446 (
1447 crate::cluster_publisher::ClusterEventPublisher,
1448 crate::activity_publisher::ActivityEventPublisher,
1449 ),
1450 ServerError,
1451> {
1452 let capacity = required_cluster_broadcast_capacity(runtime)?;
1453 Ok((
1454 crate::cluster_publisher::ClusterEventPublisher::new(capacity),
1455 build_transcript_publisher(observability_store, capacity, transcript_bounds(runtime)),
1456 ))
1457}
1458
1459fn transcript_bounds(runtime: &RuntimeConfig) -> crate::activity_bounds::TranscriptBounds {
1461 crate::activity_bounds::TranscriptBounds {
1462 max_event_bytes: runtime.observability.max_event_bytes,
1463 max_stream_events: runtime.observability.max_stream_events,
1464 }
1465}
1466
1467fn build_transcript_publisher(
1478 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1479 capacity: std::num::NonZeroUsize,
1480 bounds: crate::activity_bounds::TranscriptBounds,
1481) -> crate::activity_publisher::ActivityEventPublisher {
1482 let store = observability_store
1483 .unwrap_or_else(|| Arc::new(aion_store::InMemoryObservabilityStore::default()));
1484 crate::activity_publisher::ActivityEventPublisher::new(store, capacity).with_bounds(bounds)
1485}
1486
1487#[cfg(feature = "haematite-backend")]
1489struct RoutingState {
1490 shard_directory: Option<Arc<crate::routing::StaticShardDirectory>>,
1491 request_forwarder: Option<Arc<dyn crate::routing::RequestForwarder>>,
1492 mint_routing: Option<crate::namespace::NamespaceRouting>,
1497}
1498
1499#[cfg(feature = "haematite-backend")]
1503fn build_routing_state(
1504 cluster_store: Option<&Arc<aion_store_haematite::HaematiteStore>>,
1505 directory_peers: Vec<crate::routing::DirectoryPeer>,
1506 self_node_id: Option<String>,
1507) -> RoutingState {
1508 let Some(store) = cluster_store else {
1509 return RoutingState {
1510 shard_directory: None,
1511 request_forwarder: None,
1512 mint_routing: None,
1513 };
1514 };
1515 let shard_directory = Arc::new(crate::routing::StaticShardDirectory::new(
1516 Arc::clone(store),
1517 directory_peers,
1518 self_node_id,
1519 ));
1520 let request_forwarder: Arc<dyn crate::routing::RequestForwarder> =
1521 Arc::new(crate::routing::GrpcRequestForwarder::new());
1522 let mint_routing = build_namespace_routing(
1523 Some(store),
1524 Some(&shard_directory),
1525 Some(&request_forwarder),
1526 );
1527 RoutingState {
1528 shard_directory: Some(shard_directory),
1529 request_forwarder: Some(request_forwarder),
1530 mint_routing,
1531 }
1532}
1533
1534#[cfg(feature = "haematite-backend")]
1544fn build_namespace_routing(
1545 cluster_store: Option<&Arc<aion_store_haematite::HaematiteStore>>,
1546 shard_directory: Option<&Arc<crate::routing::StaticShardDirectory>>,
1547 request_forwarder: Option<&Arc<dyn crate::routing::RequestForwarder>>,
1548) -> Option<crate::namespace::NamespaceRouting> {
1549 use crate::namespace::{
1550 GrpcMintForwarder, MintForwarder, MintShardOwners, NamespaceRouting, NamespaceShardResolver,
1551 };
1552 let store = Arc::clone(cluster_store?);
1553 let directory = Arc::clone(shard_directory?);
1554 let shards: Arc<dyn NamespaceShardResolver> = store;
1555 let owners: Arc<dyn MintShardOwners> = directory;
1556 let forwarder: Arc<dyn MintForwarder> =
1557 Arc::new(GrpcMintForwarder::new(Arc::clone(request_forwarder?)));
1558 Some(NamespaceRouting::new(shards, owners, forwarder))
1559}
1560
1561struct ConnectedStore {
1571 event_store: Arc<dyn EventStore>,
1572 outbox_store: Option<Arc<dyn OutboxStore>>,
1573 namespace_store: Arc<dyn NamespaceStore>,
1580 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1588 bootstrap_coordinator: bool,
1589 #[cfg(feature = "haematite-backend")]
1590 cluster_responder: Option<aion_store_haematite::ClusterResponder>,
1591 #[cfg(feature = "haematite-backend")]
1595 cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
1596 #[cfg(feature = "haematite-backend")]
1599 watched_peers: Vec<crate::cluster::WatchedPeer>,
1600 #[cfg(feature = "haematite-backend")]
1604 directory_peers: Vec<crate::routing::DirectoryPeer>,
1605 #[cfg(feature = "haematite-backend")]
1609 self_node_id: Option<String>,
1610}
1611
1612impl ConnectedStore {
1613 fn local(
1620 event_store: Arc<dyn EventStore>,
1621 outbox_store: Option<Arc<dyn OutboxStore>>,
1622 namespace_store: Arc<dyn NamespaceStore>,
1623 ) -> Self {
1624 Self {
1625 event_store,
1626 outbox_store,
1627 namespace_store,
1628 observability_store: None,
1632 bootstrap_coordinator: true,
1633 #[cfg(feature = "haematite-backend")]
1634 cluster_responder: None,
1635 #[cfg(feature = "haematite-backend")]
1636 cluster_store: None,
1637 #[cfg(feature = "haematite-backend")]
1638 watched_peers: Vec::new(),
1639 #[cfg(feature = "haematite-backend")]
1640 directory_peers: Vec::new(),
1641 #[cfg(feature = "haematite-backend")]
1642 self_node_id: None,
1643 }
1644 }
1645}
1646
1647async fn connect_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
1657 match config.backend {
1658 StoreBackend::Memory => {
1659 let leaf = Arc::new(aion_store::InMemoryStore::default());
1662 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
1663 Ok(ConnectedStore::local(leaf, None, namespace_store))
1664 }
1665 StoreBackend::LibSql => {
1666 #[cfg(feature = "libsql-backend")]
1667 {
1668 connect_libsql_store(config).await
1669 }
1670 #[cfg(not(feature = "libsql-backend"))]
1671 {
1672 let _ = config;
1673 connect_libsql_store_unavailable()
1674 }
1675 }
1676 StoreBackend::Haematite => {
1677 #[cfg(feature = "haematite-backend")]
1678 {
1679 connect_haematite_store(config).await
1680 }
1681 #[cfg(not(feature = "haematite-backend"))]
1682 {
1683 let _ = config;
1684 connect_haematite_store_unavailable()
1685 }
1686 }
1687 }
1688}
1689
1690#[cfg(feature = "libsql-backend")]
1694async fn connect_libsql_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
1695 let Some(url) = config.url else {
1696 return Err(ServerError::Config {
1697 message: "store.url must not be empty when store.backend is libsql".to_owned(),
1698 });
1699 };
1700 let store = LibSqlStore::open(url.clone())
1701 .await
1702 .map_err(ServerError::from)?;
1703 store
1704 .validate_event_compatibility()
1705 .await
1706 .map_err(|error| match error {
1707 aion_store::StoreError::Serialization(_) => ServerError::Config {
1708 message: format!(
1709 "Database schema mismatch — delete {url} and restart, or run migrations."
1710 ),
1711 },
1712 other => ServerError::from(other),
1713 })?;
1714 let leaf = Arc::new(store);
1715 let event_store: Arc<dyn EventStore> = leaf.clone();
1716 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
1717 let outbox_store: Arc<dyn OutboxStore> = leaf;
1718 Ok(ConnectedStore::local(
1719 event_store,
1720 Some(outbox_store),
1721 namespace_store,
1722 ))
1723}
1724
1725#[cfg(not(feature = "libsql-backend"))]
1729fn connect_libsql_store_unavailable() -> Result<ConnectedStore, ServerError> {
1730 Err(ServerError::Config {
1731 message: "store.backend = libsql requires the aion-server `libsql-backend` feature"
1732 .to_owned(),
1733 })
1734}
1735
1736#[cfg(feature = "haematite-backend")]
1756async fn connect_haematite_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
1757 let Some(data_dir) = config.data_dir else {
1758 return Err(ServerError::Config {
1759 message: "store.data_dir must not be empty when store.backend is haematite".to_owned(),
1760 });
1761 };
1762 let shard_count = config.shard_count;
1763 let owned_shards = config.owned_shards.clone();
1764 let cluster = config.cluster.clone();
1765 let watched_peers: Vec<crate::cluster::WatchedPeer> = cluster
1770 .as_ref()
1771 .map(|cluster| {
1772 cluster
1773 .peers
1774 .iter()
1775 .map(|peer| crate::cluster::WatchedPeer {
1776 name: peer.name.clone(),
1777 owned_shards: peer.owned_shards.clone(),
1778 })
1779 .collect()
1780 })
1781 .unwrap_or_default();
1782 let directory_peers: Vec<crate::routing::DirectoryPeer> = cluster
1785 .as_ref()
1786 .map(|cluster| {
1787 cluster
1788 .peers
1789 .iter()
1790 .map(|peer| crate::routing::DirectoryPeer {
1791 name: peer.name.clone(),
1792 owned_shards: peer.owned_shards.clone(),
1793 grpc_addr: peer.grpc_address,
1794 })
1795 .collect()
1796 })
1797 .unwrap_or_default();
1798 let self_node_id: Option<String> = cluster.as_ref().map(|cluster| cluster.node_id.clone());
1801 let (store, responder) =
1805 tokio::task::spawn_blocking(move || build_haematite_store(&data_dir, shard_count, cluster))
1806 .await
1807 .map_err(|error| ServerError::Config {
1808 message: format!("haematite store initialization task failed: {error}"),
1809 })??;
1810
1811 let bootstrap_coordinator = if owned_shards.is_empty() {
1815 true
1816 } else {
1817 store.set_owned_shards(owned_shards.iter().copied());
1818 store.owns_workflow_shard(&aion::schedule_coordinator_workflow_id())
1819 };
1820
1821 let leaf = Arc::new(store);
1822 let event_store: Arc<dyn EventStore> = leaf.clone();
1823 let outbox_store: Arc<dyn OutboxStore> = leaf.clone();
1824 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
1828 let observability_store: Arc<dyn aion_store::ObservabilityStore> = leaf.clone();
1833 let cluster_store = responder.as_ref().map(|_| leaf);
1837 let (watched_peers, directory_peers, self_node_id) = if cluster_store.is_some() {
1838 (watched_peers, directory_peers, self_node_id)
1839 } else {
1840 (Vec::new(), Vec::new(), None)
1841 };
1842 Ok(ConnectedStore {
1843 event_store,
1844 outbox_store: Some(outbox_store),
1845 namespace_store,
1846 observability_store: Some(observability_store),
1847 bootstrap_coordinator,
1848 cluster_responder: responder,
1849 cluster_store,
1850 watched_peers,
1851 directory_peers,
1852 self_node_id,
1853 })
1854}
1855
1856#[cfg(feature = "haematite-backend")]
1871fn build_haematite_store(
1872 data_dir: &str,
1873 shard_count: usize,
1874 cluster: Option<crate::config::ClusterConfig>,
1875) -> Result<
1876 (
1877 aion_store_haematite::HaematiteStore,
1878 Option<aion_store_haematite::ClusterResponder>,
1879 ),
1880 ServerError,
1881> {
1882 build_haematite_store_with_hook(data_dir, shard_count, cluster, || Ok(()))
1883}
1884
1885#[cfg(feature = "haematite-backend")]
1886fn build_haematite_store_with_hook(
1887 data_dir: &str,
1888 shard_count: usize,
1889 cluster: Option<crate::config::ClusterConfig>,
1890 before_backend_touch: impl FnOnce() -> Result<(), std::io::Error>,
1891) -> Result<
1892 (
1893 aion_store_haematite::HaematiteStore,
1894 Option<aion_store_haematite::ClusterResponder>,
1895 ),
1896 ServerError,
1897> {
1898 use aion_store_haematite::{ClusterBootstrap, HaematiteStore};
1899
1900 let private_root = crate::filesystem::ConfinedDir::open_or_create(std::path::Path::new(
1908 data_dir,
1909 ))
1910 .map_err(|error| ServerError::Config {
1911 message: format!("unsafe store.data_dir `{data_dir}`: {error}"),
1912 })?;
1913
1914 for shard in 0..shard_count {
1918 private_root
1919 .create_dir_all(std::path::Path::new(&format!("shard-{shard}")))
1920 .map_err(|error| ServerError::Config {
1921 message: format!(
1922 "failed to materialize shard-{shard} under store.data_dir `{data_dir}`: {error}"
1923 ),
1924 })?;
1925 }
1926 private_root
1927 .harden_tree()
1928 .map_err(|error| private_store_mode_error(data_dir, &error))?;
1929
1930 before_backend_touch().map_err(|error| ServerError::Config {
1933 message: format!("store.data_dir pre-open hook failed: {error}"),
1934 })?;
1935
1936 #[cfg(unix)]
1937 let backend_path = private_root
1938 .backend_path()
1939 .map_err(|error| ServerError::Config {
1940 message: format!("failed to resolve held store.data_dir `{data_dir}`: {error}"),
1941 })?;
1942 #[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
1943 crate::filesystem::validate_ambient_backend_ancestors(&backend_path).map_err(|error| {
1944 let (component, reason) = error.into_parts();
1945 ServerError::UnsafeDataRootAncestor {
1946 data_root: backend_path.clone(),
1947 component,
1948 reason,
1949 }
1950 })?;
1951 #[cfg(not(unix))]
1952 let backend_path = std::path::PathBuf::from(data_dir);
1953
1954 let Some(cluster) = cluster else {
1955 let store = if backend_path.join("config.json").exists() {
1956 HaematiteStore::open(&backend_path).map_err(ServerError::from)?
1957 } else {
1958 HaematiteStore::create_with_shard_count(&backend_path, shard_count)
1959 .map_err(ServerError::from)?
1960 };
1961 store.materialize_all_shards().map_err(ServerError::from)?;
1962 private_root
1963 .harden_tree()
1964 .map_err(|error| private_store_mode_error(data_dir, &error))?;
1965 let store = store.retain_data_root_capability(private_root);
1966 return Ok((store, None));
1967 };
1968
1969 let boot = ClusterBootstrap {
1970 node_id: cluster.node_id,
1971 bind_address: cluster.bind_address,
1972 members: cluster.members,
1973 peers: cluster
1974 .peers
1975 .into_iter()
1976 .map(|peer| (peer.name, peer.address))
1977 .collect(),
1978 timeout: HAEMATITE_CLUSTER_OP_TIMEOUT,
1979 };
1980 let (store, responder) =
1981 HaematiteStore::open_or_create_distributed(&backend_path, shard_count, boot)
1982 .map_err(ServerError::from)?;
1983 store.materialize_all_shards().map_err(ServerError::from)?;
1984 private_root
1985 .harden_tree()
1986 .map_err(|error| private_store_mode_error(data_dir, &error))?;
1987 let store = store.retain_data_root_capability(private_root);
1988 Ok((store, Some(responder)))
1989}
1990
1991#[cfg(feature = "haematite-backend")]
1992fn private_store_mode_error(data_dir: &str, error: &std::io::Error) -> ServerError {
1993 ServerError::Config {
1994 message: format!(
1995 "failed to apply private modes under store.data_dir `{data_dir}`: {error}"
1996 ),
1997 }
1998}
1999
2000#[cfg(feature = "haematite-backend")]
2002const HAEMATITE_CLUSTER_OP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
2003
2004#[cfg(not(feature = "haematite-backend"))]
2008fn connect_haematite_store_unavailable() -> Result<ConnectedStore, ServerError> {
2009 Err(ServerError::Config {
2010 message: "store.backend = haematite requires the aion-server `haematite-backend` feature"
2011 .to_owned(),
2012 })
2013}
2014
2015#[cfg(not(feature = "liminal-transport"))]
2024#[derive(Clone, Debug)]
2025struct NullInterventionTransport;
2026
2027#[cfg(not(feature = "liminal-transport"))]
2028#[async_trait::async_trait]
2029impl crate::worker::InterventionTransport for NullInterventionTransport {
2030 async fn push(
2031 &self,
2032 _worker: &crate::worker::WorkerHandle,
2033 _command: aion_core::InterventionCommand,
2034 ) -> Result<aion_core::InterventionOutcome, ServerError> {
2035 Err(ServerError::worker_connection_lost(
2036 "intervention",
2037 "no intervention push transport is compiled in".to_owned(),
2038 ))
2039 }
2040}
2041
2042#[cfg(test)]
2043mod tests {
2044 use std::{net::SocketAddr, time::Duration};
2045
2046 use aion_store::InMemoryStore;
2047
2048 use super::ServerState;
2049 use crate::config::{
2050 AuthConfig, AuthoringConfig, DeployConfig, DevConfig, ListenConfig, MetricsConfig,
2051 NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig, OutboxConfig,
2052 RuntimeConfig, WebSocketConfig, WorkerConfig,
2053 };
2054
2055 fn runtime_config() -> RuntimeConfig {
2056 RuntimeConfig {
2057 listen: ListenConfig {
2058 grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
2059 http: SocketAddr::from(([127, 0, 0, 1], 8080)),
2060 },
2061 tls: None,
2062 auth: AuthConfig {
2063 enabled: false,
2064 jwks_url: None,
2065 jwks_refresh_seconds: 300,
2066 },
2067 ops_console: OpsConsoleConfig {
2068 source: OpsConsoleAssetSource::Embedded,
2069 },
2070 namespace: NamespaceConfig {
2071 mode: NamespaceMode::SharedEngine,
2072 },
2073 worker: WorkerConfig {
2074 heartbeat_window: Duration::from_secs(30),
2075 ..WorkerConfig::default()
2076 },
2077 websocket: WebSocketConfig {
2078 outbound_buffer_bound: 32,
2079 event_broadcast_capacity: Some(64),
2080 cluster_broadcast_capacity: Some(64),
2081 },
2082 workflow_packages: Vec::new(),
2083 deploy: DeployConfig::default(),
2084 authoring: AuthoringConfig::default(),
2085 dev: DevConfig::default(),
2086 outbox: OutboxConfig::default(),
2087 observability: crate::config::ObservabilityConfig::default(),
2088 scheduler_threads: 1,
2089 query_timeout: Some(Duration::from_secs(10)),
2090 default_namespace: "default".to_owned(),
2091 auto_create: crate::config::AutoCreate::Open,
2092 max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
2093 drain_timeout: Duration::from_secs(30),
2094 metrics: MetricsConfig { enabled: true },
2095 owned_shards: Vec::new(),
2096 cors_allowed_origins: Vec::new(),
2097 }
2098 }
2099
2100 #[tokio::test]
2101 async fn builds_state_with_in_memory_store() -> Result<(), Box<dyn std::error::Error>> {
2102 let state =
2103 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2104
2105 std::hint::black_box(state.namespace_guard());
2106 std::hint::black_box(state.worker_registry());
2107
2108 Ok(())
2109 }
2110
2111 #[tokio::test]
2119 async fn unserved_queues_surfaces_a_parked_dispatch_and_clears_it()
2120 -> Result<(), Box<dyn std::error::Error>> {
2121 use aion::{ActivityDispatch, ActivityDispatcher as _};
2122 use aion_core::{ActivityId, RunId, WorkflowId};
2123 use std::sync::Arc;
2124
2125 let state =
2126 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2127 assert!(
2128 state.unserved_queues()?.is_empty(),
2129 "a calm boot has no unserved queues"
2130 );
2131 assert!(state.queue_declarations().is_installed());
2135 assert_eq!(
2136 state
2137 .queue_declarations()
2138 .declaration_for("nobody-serves-this"),
2139 crate::worker::QueueDeclaration::Unknown
2140 );
2141
2142 let dispatcher = Arc::new(
2143 crate::worker::WorkerActivityDispatcher::new(
2144 state.worker_registry().clone(),
2145 "default",
2146 crate::worker::HeartbeatTracker::new(Duration::from_secs(5)),
2147 )
2148 .with_queue_state(state.queue_service_state().clone())
2149 .with_queue_declarations(state.queue_declarations().clone()),
2150 );
2151 let workflow_id = WorkflowId::new_v4();
2152 let request = ActivityDispatch {
2153 namespace: "default".to_owned(),
2154 task_queue: "nobody-serves-this".to_owned(),
2155 node: None,
2156 workflow_id: workflow_id.clone(),
2157 run_id: RunId::new_v4(),
2158 activity_id: ActivityId::from_sequence_position(0),
2159 name: "greet".to_owned(),
2160 input: "{}".to_owned(),
2161 config: "{}".to_owned(),
2162 attempt: 1,
2163 advisory: false,
2164 labels: std::collections::BTreeMap::new(),
2165 };
2166 let parked = std::thread::spawn(move || dispatcher.dispatch(request));
2167
2168 let mut unserved = Vec::new();
2169 for _ in 0..30 {
2170 unserved = state.unserved_queues()?;
2171 if !unserved.is_empty() {
2172 break;
2173 }
2174 tokio::time::sleep(Duration::from_millis(100)).await;
2175 }
2176 assert_eq!(unserved.len(), 1, "the parked dispatch is not surfaced");
2177 assert_eq!(
2178 unserved[0].reason,
2179 crate::worker::QueueServiceReason::NoLivePollers,
2180 "an empty catalog must not be read as a structural refusal"
2181 );
2182 assert_eq!(unserved[0].key.task_queue, "nobody-serves-this");
2183 assert_eq!(unserved[0].waiting.len(), 1);
2184 assert_eq!(unserved[0].waiting[0].workflow_id, workflow_id);
2185
2186 let (worker_tx, worker_rx) = tokio::sync::mpsc::channel(1);
2188 drop(worker_rx);
2189 let registration = state.worker_registry().register_namespaces(
2190 [String::from("default")],
2191 "nobody-serves-this",
2192 None,
2193 [String::from("greet")].iter(),
2194 worker_tx,
2195 )?;
2196 let outcome = parked.join().map_err(|_| "parked dispatch panicked")?;
2197 assert!(outcome.is_err(), "the released dispatch must resolve");
2198 assert!(
2199 state.unserved_queues()?.is_empty(),
2200 "a resolved dispatch must leave the unserved state"
2201 );
2202 registration.deregister()?;
2203 Ok(())
2204 }
2205
2206 #[tokio::test]
2207 async fn namespace_store_is_reachable_and_functional_after_default_boot()
2208 -> Result<(), Box<dyn std::error::Error>> {
2209 use aion_store::{MintOutcome, NamespaceOrigin};
2210
2211 let state =
2215 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2216
2217 let store = state.namespace_store();
2218
2219 let outcome = store
2221 .register_namespace("orders", NamespaceOrigin::WorkerMint)
2222 .await?;
2223 assert_eq!(
2224 outcome,
2225 MintOutcome::Created,
2226 "the first reference to a namespace mints it"
2227 );
2228
2229 let again = store
2231 .register_namespace("orders", NamespaceOrigin::WorkerMint)
2232 .await?;
2233 assert_eq!(
2234 again,
2235 MintOutcome::AlreadyExisted,
2236 "a second reference touches the existing record rather than re-creating it"
2237 );
2238
2239 let fetched = store.get_namespace("orders").await?;
2241 let record = fetched.ok_or("registered namespace must be retrievable via get_namespace")?;
2242 assert_eq!(record.name, "orders");
2243 assert_eq!(record.origin, NamespaceOrigin::WorkerMint);
2244
2245 let listed = store.list_namespaces().await?;
2247 assert!(
2248 listed.iter().any(|record| record.name == "orders"),
2249 "list_namespaces returns the minted namespace"
2250 );
2251
2252 Ok(())
2253 }
2254
2255 #[cfg(feature = "haematite-backend")]
2256 #[tokio::test(flavor = "multi_thread")]
2257 async fn connect_store_haematite_round_trips_through_event_store()
2258 -> Result<(), Box<dyn std::error::Error>> {
2259 use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
2260 use aion_store::WriteToken;
2261 use chrono::Utc;
2262
2263 use crate::config::{StoreBackend, StoreConfig};
2264
2265 let data_dir = crate::test_support::private_tempdir()?;
2266 let connected = super::connect_store(StoreConfig {
2270 backend: StoreBackend::Haematite,
2271 url: None,
2272 owned_shards: Vec::new(),
2273 data_dir: Some(data_dir.path().to_string_lossy().into_owned()),
2274 shard_count: 1,
2275 cluster: None,
2276 })
2277 .await?;
2278 let event_store = connected.event_store;
2279 assert!(
2280 connected.outbox_store.is_some(),
2281 "the haematite backend shares its leaf store as the dispatcher's outbox store"
2282 );
2283 assert!(
2284 connected.bootstrap_coordinator,
2285 "a single-node haematite boot owns all shards and bootstraps the coordinator"
2286 );
2287 assert!(
2288 connected.cluster_responder.is_none(),
2289 "a single-node (no [cluster]) haematite boot has no distributed responder"
2290 );
2291
2292 let workflow_id = WorkflowId::new_v4();
2293 let event = aion_core::Event::WorkflowStarted {
2294 envelope: EventEnvelope {
2295 seq: 1,
2296 recorded_at: Utc::now(),
2297 workflow_id: workflow_id.clone(),
2298 },
2299 workflow_type: String::from("checkout"),
2300 input: Payload::new(ContentType::Json, b"{}".to_vec()),
2301 run_id: RunId::new_v4(),
2302 parent_run_id: None,
2303 package_version: PackageVersion::new("a".repeat(64)),
2304 };
2305 event_store
2306 .append(
2307 WriteToken::recorder(),
2308 &workflow_id,
2309 std::slice::from_ref(&event),
2310 0,
2311 )
2312 .await?;
2313 let history = event_store.read_history(&workflow_id).await?;
2314 assert_eq!(
2315 history.len(),
2316 1,
2317 "an event appended through the server's dyn EventStore reads back"
2318 );
2319 Ok(())
2320 }
2321
2322 #[cfg(all(feature = "haematite-backend", unix))]
2323 #[test]
2324 fn haematite_root_swap_before_first_backend_touch_cannot_redirect_writes()
2325 -> Result<(), Box<dyn std::error::Error>> {
2326 use std::os::unix::fs::symlink;
2327
2328 let sandbox = crate::test_support::private_tempdir()?;
2329 let configured_root = sandbox.path().join("data");
2330 let held_root = sandbox.path().join("held-data");
2331 let outside = sandbox.path().join("outside");
2332 std::fs::create_dir(&outside)?;
2333 let configured = configured_root
2334 .to_str()
2335 .ok_or("temporary data path was not UTF-8")?;
2336
2337 let (store, responder) =
2338 super::build_haematite_store_with_hook(configured, 4, None, || {
2339 std::fs::rename(&configured_root, &held_root)?;
2344 symlink(&outside, &configured_root)?;
2345 Ok(())
2346 })?;
2347 assert!(responder.is_none());
2348
2349 let outside_entries = std::fs::read_dir(&outside)?.collect::<Result<Vec<_>, _>>()?;
2350 assert!(
2351 outside_entries.is_empty(),
2352 "Haematite followed the replaced ambient root and wrote outside"
2353 );
2354 assert!(held_root.join("config.json").is_file());
2355 for shard in 0..4 {
2356 let shard_path = held_root.join(format!("shard-{shard}"));
2357 assert!(shard_path.is_dir(), "shard {shard} was not materialized");
2358 assert!(
2359 std::fs::read_dir(&shard_path)?
2360 .next()
2361 .transpose()?
2362 .is_some(),
2363 "shard {shard} did not run Haematite's materialization path"
2364 );
2365 }
2366
2367 drop(store);
2368 Ok(())
2369 }
2370
2371 #[cfg(all(
2372 feature = "haematite-backend",
2373 any(target_os = "linux", target_os = "android")
2374 ))]
2375 #[tokio::test]
2376 async fn proc_fd_backend_path_survives_a_post_startup_root_swap()
2377 -> Result<(), Box<dyn std::error::Error>> {
2378 use std::os::unix::fs::symlink;
2379
2380 use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
2381 use aion_store::{WritableEventStore as _, WriteToken};
2382 use chrono::Utc;
2383
2384 let sandbox = crate::test_support::private_tempdir()?;
2385 let configured_root = sandbox.path().join("data");
2386 let held_root = sandbox.path().join("held-data");
2387 let capture = sandbox.path().join("capture");
2388 std::fs::create_dir(&capture)?;
2389 let configured = configured_root
2390 .to_str()
2391 .ok_or("temporary data path was not UTF-8")?;
2392
2393 let (store, responder) = super::build_haematite_store(configured, 4, None)?;
2394 assert!(responder.is_none());
2395 std::fs::rename(&configured_root, &held_root)?;
2396 symlink(&capture, &configured_root)?;
2397
2398 let workflow_id = WorkflowId::new_v4();
2399 let event = aion_core::Event::WorkflowStarted {
2400 envelope: EventEnvelope {
2401 seq: 1,
2402 recorded_at: Utc::now(),
2403 workflow_id: workflow_id.clone(),
2404 },
2405 workflow_type: String::from("post-startup-root-swap"),
2406 input: Payload::new(ContentType::Json, b"{}".to_vec()),
2407 run_id: RunId::new_v4(),
2408 parent_run_id: None,
2409 package_version: PackageVersion::new("a".repeat(64)),
2410 };
2411 store
2412 .append(
2413 WriteToken::recorder(),
2414 &workflow_id,
2415 std::slice::from_ref(&event),
2416 0,
2417 )
2418 .await?;
2419
2420 let captured = std::fs::read_dir(&capture)?.collect::<Result<Vec<_>, _>>()?;
2421 assert!(
2422 captured.is_empty(),
2423 "post-startup append followed the replacement symlink into capture"
2424 );
2425 assert!(held_root.join("config.json").is_file());
2426 drop(store);
2427 Ok(())
2428 }
2429
2430 #[cfg(all(
2431 feature = "haematite-backend",
2432 unix,
2433 not(any(target_os = "linux", target_os = "android"))
2434 ))]
2435 #[test]
2436 fn path_ambient_haematite_refuses_group_or_world_writable_ancestors()
2437 -> Result<(), Box<dyn std::error::Error>> {
2438 use std::os::unix::fs::PermissionsExt as _;
2439
2440 let sandbox = crate::test_support::private_tempdir()?;
2441 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2442
2443 for mode in [0o770, 0o1777] {
2444 let shared = sandbox.path().join(format!("shared-{mode:o}"));
2445 let data_root = shared.join("data");
2446 std::fs::create_dir(&shared)?;
2447 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(mode))?;
2448 std::fs::create_dir(&data_root)?;
2449 std::fs::set_permissions(&data_root, std::fs::Permissions::from_mode(0o700))?;
2450 let configured = data_root
2451 .to_str()
2452 .ok_or("temporary data path was not UTF-8")?;
2453
2454 let Err(error) = super::build_haematite_store(configured, 4, None) else {
2455 return Err(format!("mode {mode:04o} ancestor was accepted").into());
2456 };
2457 let message = error.to_string();
2458 let crate::ServerError::UnsafeDataRootAncestor {
2459 data_root: resolved_root,
2460 component,
2461 reason,
2462 } = error
2463 else {
2464 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
2465 };
2466 assert_eq!(resolved_root, std::fs::canonicalize(&data_root)?);
2467 assert_eq!(component, std::fs::canonicalize(&shared)?);
2468 assert!(
2469 reason.contains(&format!("mode {mode:04o}")),
2470 "unexpected reason: {reason}"
2471 );
2472 if mode & 0o1000 != 0 {
2473 assert!(reason.contains("sticky bit is not accepted"));
2474 }
2475 assert!(message.contains("private Aion home"));
2476 assert!(
2477 !data_root.join("config.json").exists(),
2478 "Haematite touched its ambient path before the refusal"
2479 );
2480 }
2481 Ok(())
2482 }
2483
2484 #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2485 #[test]
2486 fn path_ambient_haematite_refuses_mutating_allow_acl_ancestor()
2487 -> Result<(), Box<dyn std::error::Error>> {
2488 use std::os::unix::fs::PermissionsExt as _;
2489
2490 let sandbox = crate::test_support::private_tempdir()?;
2491 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2492 let shared = sandbox.path().join("acl-shared");
2493 let data_root = shared.join("data");
2494 std::fs::create_dir(&shared)?;
2495 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o700))?;
2496 let acl = "everyone allow list,search,add_file,add_subdirectory,delete_child";
2497 let status = std::process::Command::new("chmod")
2498 .arg("+a")
2499 .arg(acl)
2500 .arg(&shared)
2501 .status()?;
2502 assert!(status.success(), "failed to install Darwin regression ACL");
2503 let configured = data_root
2504 .to_str()
2505 .ok_or("temporary data path was not UTF-8")?;
2506
2507 let result = super::build_haematite_store(configured, 4, None);
2508 let cleanup = std::process::Command::new("chmod")
2509 .arg("-RN")
2510 .arg(&shared)
2511 .status()?;
2512 assert!(cleanup.success(), "failed to clean Darwin regression ACL");
2513
2514 let Err(error) = result else {
2515 return Err("mutating non-euid allow ACL ancestor was accepted".into());
2516 };
2517 let message = error.to_string();
2518 let crate::ServerError::UnsafeDataRootAncestor {
2519 component, reason, ..
2520 } = error
2521 else {
2522 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
2523 };
2524 assert_eq!(component, std::fs::canonicalize(&shared)?);
2525 assert!(
2526 reason.contains("allow"),
2527 "reason did not name the ACE: {reason}"
2528 );
2529 assert!(
2530 reason.contains("everyone"),
2531 "reason did not name the ACE principal: {reason}"
2532 );
2533 assert!(
2534 !data_root.join("config.json").exists(),
2535 "Haematite touched its ambient path before the ACL refusal"
2536 );
2537 Ok(())
2538 }
2539
2540 #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2541 #[test]
2542 fn path_ambient_haematite_accepts_the_euid_uuid_allow_ace()
2543 -> Result<(), Box<dyn std::error::Error>> {
2544 use std::os::unix::fs::PermissionsExt as _;
2545
2546 use exacl::{AclEntry, AclOption, Perm};
2547
2548 let sandbox = crate::test_support::private_tempdir()?;
2549 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2550 let private_parent = sandbox.path().join("euid-uuid-allow");
2551 let data_root = private_parent.join("data");
2552 std::fs::create_dir(&private_parent)?;
2553 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
2554
2555 let server_uid = rustix::process::geteuid().as_raw();
2556 let ace_qualifier = crate::filesystem::darwin_user_uuid_for_test(server_uid)?;
2557 let entry = AclEntry::allow_user(
2558 &ace_qualifier.to_string(),
2559 Perm::EXECUTE | Perm::WRITE | Perm::APPEND | Perm::DELETE_CHILD,
2560 None,
2561 );
2562 exacl::setfacl(
2563 &[private_parent.as_path()],
2564 &[entry],
2565 AclOption::SYMLINK_ACL,
2566 )?;
2567 let configured = data_root
2568 .to_str()
2569 .ok_or("temporary data path was not UTF-8")?;
2570
2571 let result = super::build_haematite_store(configured, 4, None);
2572 let cleanup = std::process::Command::new("chmod")
2573 .arg("-RN")
2574 .arg(&private_parent)
2575 .status()?;
2576 assert!(cleanup.success(), "failed to clean euid UUID allow ACL");
2577
2578 let (store, responder) = result?;
2579 assert!(responder.is_none());
2580 assert!(data_root.join("config.json").is_file());
2581 drop(store);
2582 Ok(())
2583 }
2584
2585 #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2586 #[test]
2587 fn path_ambient_haematite_refuses_a_non_euid_user_uuid_allow_ace()
2588 -> Result<(), Box<dyn std::error::Error>> {
2589 use std::os::unix::fs::PermissionsExt as _;
2590
2591 use exacl::{AclEntry, AclOption, Perm};
2592
2593 let sandbox = crate::test_support::private_tempdir()?;
2594 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2595 let shared = sandbox.path().join("non-euid-uuid-allow");
2596 let data_root = shared.join("data");
2597 std::fs::create_dir(&shared)?;
2598 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o700))?;
2599
2600 let server_uid = rustix::process::geteuid().as_raw();
2601 let foreign_uid = u32::from(server_uid == 0);
2602 let foreign_qualifier = crate::filesystem::darwin_user_uuid_for_test(foreign_uid)?;
2603 let entry = AclEntry::allow_user(
2604 &foreign_qualifier.to_string(),
2605 Perm::EXECUTE | Perm::WRITE | Perm::APPEND | Perm::DELETE_CHILD,
2606 None,
2607 );
2608 exacl::setfacl(&[shared.as_path()], &[entry], AclOption::SYMLINK_ACL)?;
2609 let configured = data_root
2610 .to_str()
2611 .ok_or("temporary data path was not UTF-8")?;
2612
2613 let result = super::build_haematite_store(configured, 4, None);
2614 let cleanup = std::process::Command::new("chmod")
2615 .arg("-RN")
2616 .arg(&shared)
2617 .status()?;
2618 assert!(cleanup.success(), "failed to clean non-euid UUID allow ACL");
2619
2620 let Err(error) = result else {
2621 return Err("mutating non-euid user UUID allow ACE was accepted".into());
2622 };
2623 let message = error.to_string();
2624 let crate::ServerError::UnsafeDataRootAncestor {
2625 component, reason, ..
2626 } = error
2627 else {
2628 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
2629 };
2630 assert_eq!(component, std::fs::canonicalize(&shared)?);
2631 assert!(
2632 reason.contains("allow") && reason.contains(&format!("server euid {server_uid}")),
2633 "reason did not name the rejected ACE: {reason}"
2634 );
2635 assert!(
2636 !data_root.join("config.json").exists(),
2637 "Haematite touched its ambient path before the UUID ACL refusal"
2638 );
2639 Ok(())
2640 }
2641
2642 #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2643 #[test]
2644 fn path_ambient_haematite_accepts_a_deny_only_acl_ancestor()
2645 -> Result<(), Box<dyn std::error::Error>> {
2646 use std::os::unix::fs::PermissionsExt as _;
2647
2648 let sandbox = crate::test_support::private_tempdir()?;
2649 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2650 let private_parent = sandbox.path().join("deny-only");
2651 let data_root = private_parent.join("data");
2652 std::fs::create_dir(&private_parent)?;
2653 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
2654 let status = std::process::Command::new("chmod")
2655 .arg("+a")
2656 .arg("everyone deny delete")
2657 .arg(&private_parent)
2658 .status()?;
2659 assert!(status.success(), "failed to install Darwin deny-only ACL");
2660 let configured = data_root
2661 .to_str()
2662 .ok_or("temporary data path was not UTF-8")?;
2663
2664 let result = super::build_haematite_store(configured, 4, None);
2665 let cleanup = std::process::Command::new("chmod")
2666 .arg("-RN")
2667 .arg(&private_parent)
2668 .status()?;
2669 assert!(cleanup.success(), "failed to clean Darwin deny-only ACL");
2670
2671 let (store, responder) = result?;
2672 assert!(responder.is_none());
2673 assert!(data_root.join("config.json").is_file());
2674 drop(store);
2675 Ok(())
2676 }
2677
2678 #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2679 #[test]
2680 fn path_ambient_haematite_accepts_the_stock_home_acl_chain()
2681 -> Result<(), Box<dyn std::error::Error>> {
2682 use std::os::unix::fs::PermissionsExt as _;
2683 use users::os::unix::UserExt as _;
2684
2685 let effective_uid = rustix::process::geteuid().as_raw();
2686 let effective_user = users::get_user_by_uid(effective_uid)
2687 .ok_or_else(|| format!("server euid {effective_uid} has no account record"))?;
2688 let sandbox = tempfile::Builder::new()
2689 .prefix(".aion-acl-home-proof-")
2690 .tempdir_in(effective_user.home_dir())?;
2691 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2692 let data_root = sandbox.path().join("data");
2693 let configured = data_root
2694 .to_str()
2695 .ok_or("temporary data path was not UTF-8")?;
2696
2697 let (store, responder) = super::build_haematite_store(configured, 4, None)?;
2698 assert!(responder.is_none());
2699 assert!(data_root.join("config.json").is_file());
2700 drop(store);
2701 Ok(())
2702 }
2703
2704 #[cfg(all(
2705 feature = "haematite-backend",
2706 unix,
2707 not(any(target_os = "linux", target_os = "android"))
2708 ))]
2709 #[test]
2710 fn path_ambient_haematite_accepts_an_owner_controlled_chain()
2711 -> Result<(), Box<dyn std::error::Error>> {
2712 use std::os::unix::fs::PermissionsExt as _;
2713
2714 let sandbox = crate::test_support::private_tempdir()?;
2715 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2716 let private_parent = sandbox.path().join("private");
2717 let data_root = private_parent.join("data");
2718 std::fs::create_dir(&private_parent)?;
2719 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
2720 let configured = data_root
2721 .to_str()
2722 .ok_or("temporary data path was not UTF-8")?;
2723
2724 let (store, responder) = super::build_haematite_store(configured, 4, None)?;
2725 assert!(responder.is_none());
2726 assert!(data_root.join("config.json").is_file());
2727 for shard in 0..4 {
2728 assert!(data_root.join(format!("shard-{shard}")).is_dir());
2729 }
2730 drop(store);
2731 Ok(())
2732 }
2733
2734 #[tokio::test]
2735 async fn connect_store_memory_backend_exposes_no_outbox_store()
2736 -> Result<(), Box<dyn std::error::Error>> {
2737 use crate::config::{StoreBackend, StoreConfig};
2738
2739 let connected = super::connect_store(StoreConfig {
2742 backend: StoreBackend::Memory,
2743 url: None,
2744 owned_shards: Vec::new(),
2745 data_dir: None,
2746 shard_count: 1,
2747 cluster: None,
2748 })
2749 .await?;
2750 assert!(
2751 connected.outbox_store.is_none(),
2752 "the in-memory backend exposes no outbox store"
2753 );
2754 Ok(())
2755 }
2756
2757 #[cfg(feature = "libsql-backend")]
2761 #[tokio::test]
2762 async fn connect_store_shares_outbox_store_only_for_libsql()
2763 -> Result<(), Box<dyn std::error::Error>> {
2764 use crate::config::{StoreBackend, StoreConfig};
2765
2766 let path = std::env::temp_dir().join(format!(
2771 "aion-connect-store-{}-{}.db",
2772 std::process::id(),
2773 std::time::SystemTime::now()
2774 .duration_since(std::time::UNIX_EPOCH)
2775 .map(|elapsed| elapsed.as_nanos())
2776 .unwrap_or_default()
2777 ));
2778 let connected = super::connect_store(StoreConfig {
2779 backend: StoreBackend::LibSql,
2780 url: Some(path.to_string_lossy().into_owned()),
2781 owned_shards: Vec::new(),
2782 data_dir: None,
2783 shard_count: 1,
2784 cluster: None,
2785 })
2786 .await?;
2787 assert!(
2788 connected.outbox_store.is_some(),
2789 "the libSQL backend shares its leaf store as the dispatcher's outbox store"
2790 );
2791 Ok(())
2792 }
2793
2794 #[tokio::test]
2795 async fn state_build_fails_without_event_broadcast_capacity()
2796 -> Result<(), Box<dyn std::error::Error>> {
2797 let mut runtime = runtime_config();
2798 runtime.websocket.event_broadcast_capacity = None;
2799
2800 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
2801 .await
2802 .err()
2803 .ok_or("state build must fail when event streaming is unsized")?;
2804
2805 assert!(error.is_config(), "expected a config error, got {error}");
2806 assert!(
2807 error
2808 .to_string()
2809 .contains("websocket.event_broadcast_capacity"),
2810 "error must name the missing key: {error}"
2811 );
2812 Ok(())
2813 }
2814
2815 #[tokio::test]
2816 async fn state_build_fails_without_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
2817 let mut runtime = runtime_config();
2818 runtime.query_timeout = None;
2819
2820 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
2821 .await
2822 .err()
2823 .ok_or("state build must fail when the query reply deadline is unset")?;
2824
2825 assert!(error.is_config(), "expected a config error, got {error}");
2826 assert!(
2827 error.to_string().contains("runtime.query_timeout_ms"),
2828 "error must name the missing key: {error}"
2829 );
2830 assert!(
2831 error.to_string().contains("AION_RUNTIME_QUERY_TIMEOUT_MS"),
2832 "error must name the environment override: {error}"
2833 );
2834 Ok(())
2835 }
2836
2837 #[tokio::test]
2838 async fn state_build_fails_with_zero_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
2839 let mut runtime = runtime_config();
2840 runtime.query_timeout = Some(Duration::ZERO);
2841
2842 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
2843 .await
2844 .err()
2845 .ok_or("state build must fail when the query reply deadline is zero")?;
2846
2847 assert!(error.is_config(), "expected a config error, got {error}");
2848 assert!(
2849 error.to_string().contains("runtime.query_timeout_ms"),
2850 "error must name the zero-valued key: {error}"
2851 );
2852 Ok(())
2853 }
2854}