1use std::{path::PathBuf, sync::Arc};
4
5use aion::{
6 ActivityDispatcher, EngineBuilder, RuntimeHandle, SignalRouter, signal::ConcreteSignalRouter,
7};
8use aion_store::{EventStore, NamespaceStore, OutboxStore, WorkerDeploymentStore};
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 supervisor::WorkerSupervisor,
28 },
29};
30
31fn new_supervisor(
42 store: &Arc<dyn WorkerDeploymentStore>,
43 publisher: &crate::cluster_publisher::ClusterEventPublisher,
44) -> Arc<WorkerSupervisor> {
45 Arc::new(WorkerSupervisor::new(Arc::clone(store), publisher.clone()))
46}
47
48#[derive(Clone)]
50pub struct ServerState {
51 inner: Arc<ServerStateInner>,
52}
53
54struct ServerStateInner {
55 namespace_guard: NamespaceGuard,
56 runtime: RuntimeConfig,
57 worker_registry: ConnectedWorkerRegistry,
58 pending_activities: PendingActivities,
59 heartbeat_tracker: HeartbeatTracker,
60 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters,
67 drain_state: DrainState,
68 metrics: Option<Metrics>,
69 health: Option<HealthState>,
70 activity_mock_registry: Option<ActivityMockRegistry>,
73 outbox_store: Option<Arc<dyn OutboxStore>>,
77 namespace_store: Arc<dyn NamespaceStore>,
86 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
88 worker_supervisor: Arc<WorkerSupervisor>,
93 outbox_wake: Arc<tokio::sync::Notify>,
100 cluster_publisher: crate::cluster_publisher::ClusterEventPublisher,
104 transcript_publisher: crate::activity_publisher::ActivityEventPublisher,
113 attempt_owners: crate::worker::AttemptOwnerIndex,
120 queue_service_state: crate::worker::QueueServiceState,
126 queue_declarations: crate::worker::QueueDeclarationSource,
130 update_status: crate::update_check::UpdateStatusState,
136 workspace_root: crate::worker::WorkspaceRoot,
144 cluster_self_node: Option<String>,
149 #[cfg(feature = "haematite-backend")]
154 cluster_responder: Option<aion_store_haematite::ClusterResponder>,
155 #[cfg(feature = "haematite-backend")]
158 cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
159 #[cfg(feature = "haematite-backend")]
162 watched_peers: Vec<crate::cluster::WatchedPeer>,
163 #[cfg(feature = "haematite-backend")]
168 shard_directory: Option<Arc<crate::routing::StaticShardDirectory>>,
169 #[cfg(feature = "haematite-backend")]
173 request_forwarder: Option<Arc<dyn crate::routing::RequestForwarder>>,
174 #[cfg(feature = "auth")]
175 jwks_cache: Option<JwksCache>,
176}
177
178impl ServerState {
179 const FALLBACK_CLUSTER_BROADCAST_CAPACITY: std::num::NonZeroUsize =
188 match std::num::NonZeroUsize::new(64) {
189 Some(value) => value,
190 None => std::num::NonZeroUsize::MIN,
191 };
192
193 pub async fn build(config: ServerConfig) -> Result<Self, ServerError> {
200 let (store_config, runtime) = config.into_parts();
201 let connected = connect_store(store_config).await?;
202 Self::build_with_connected_store(connected, runtime).await
203 }
204
205 pub async fn build_with_store<S>(store: S, runtime: RuntimeConfig) -> Result<Self, ServerError>
211 where
212 S: EventStore + NamespaceStore + WorkerDeploymentStore,
213 {
214 let leaf = Arc::new(store);
218 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
219 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
220 Self::build_with_connected_store(
221 ConnectedStore::local(leaf, None, namespace_store, worker_deployment_store),
222 runtime,
223 )
224 .await
225 }
226
227 async fn build_with_connected_store(
228 connected: ConnectedStore,
229 runtime: RuntimeConfig,
230 ) -> Result<Self, ServerError> {
231 let cluster_self_node = connected.cluster_self_node();
232 let outbox_store = connected.outbox_store;
233 let bootstrap_coordinator = connected.bootstrap_coordinator;
234 #[cfg(feature = "haematite-backend")]
235 let cluster_responder = connected.cluster_responder;
236 #[cfg(feature = "haematite-backend")]
237 let cluster_store = connected.cluster_store;
238 #[cfg(feature = "haematite-backend")]
239 let watched_peers = connected.watched_peers;
240 #[cfg(feature = "haematite-backend")]
244 let RoutingState {
245 shard_directory,
246 request_forwarder,
247 mint_routing,
248 } = build_routing_state(
249 cluster_store.as_ref(),
250 connected.directory_peers,
251 connected.self_node_id,
252 );
253 let (event_broadcast_capacity, query_timeout) = required_engine_seams(&runtime)?;
254 let (cluster_publisher, transcript_publisher) =
255 build_real_time_publishers(&runtime, connected.observability_store)?;
256 let (metrics, outbox_wake, instrumented_store) =
257 build_instrumented_store(&runtime, connected.event_store)?;
258 let exported_metrics = runtime.metrics.enabled.then_some(metrics.clone());
259 #[cfg(not(feature = "haematite-backend"))]
260 let mint_routing: Option<crate::namespace::NamespaceRouting> = None;
261 let seams = build_worker_seams(
262 &runtime,
263 &cluster_publisher,
264 &connected.namespace_store,
265 &connected.worker_deployment_store,
266 mint_routing,
267 );
268 let (
269 activity_dispatcher,
270 activity_mock_registry,
271 attempt_owners,
272 workspace_root,
273 update_status,
274 ) = build_decorated_dispatcher(&runtime, &seams, transcript_publisher.clone());
275
276 let engine = build_engine(EngineAssembly {
277 instrumented_store: &instrumented_store,
278 event_broadcast_capacity,
279 query_timeout,
280 activity_dispatcher,
281 active_registry: Arc::new(aion::Registry::default()),
282 bootstrap_coordinator,
283 runtime: &runtime,
284 })
285 .await?;
286 let engine = Arc::new(engine);
287 install_engine_backed_seams(&seams, &engine, runtime.outbox.enabled);
288 let resolver = NamespaceResolver::from_config(runtime.namespace.clone(), engine);
289 let worker_supervisor =
290 new_supervisor(&connected.worker_deployment_store, &cluster_publisher);
291 #[cfg(feature = "auth")]
292 let jwks_cache = build_jwks_cache(&runtime).await?;
293 Ok(Self {
294 inner: Arc::new(ServerStateInner {
295 namespace_guard: NamespaceGuard::new(resolver),
296 runtime,
297 metrics: exported_metrics,
298 worker_registry: seams.worker_registry,
299 pending_activities: seams.pending_activities,
300 heartbeat_tracker: seams.heartbeat_tracker,
301 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
302 drain_state: seams.drain_state,
303 health: Some(HealthState::new(instrumented_store, true)),
304 activity_mock_registry,
305 outbox_store,
306 namespace_store: connected.namespace_store,
307 worker_supervisor,
308 worker_deployment_store: connected.worker_deployment_store,
309 outbox_wake,
310 cluster_publisher,
311 transcript_publisher,
312 attempt_owners,
313 queue_service_state: seams.queue_service_state,
314 queue_declarations: seams.queue_declarations,
315 update_status,
316 workspace_root,
317 cluster_self_node,
318 #[cfg(feature = "haematite-backend")]
319 cluster_responder,
320 #[cfg(feature = "haematite-backend")]
321 cluster_store,
322 #[cfg(feature = "haematite-backend")]
323 watched_peers,
324 #[cfg(feature = "haematite-backend")]
325 shard_directory,
326 #[cfg(feature = "haematite-backend")]
327 request_forwarder,
328 #[cfg(feature = "auth")]
329 jwks_cache,
330 }),
331 })
332 }
333
334 #[must_use]
336 pub fn from_parts(namespace_resolver: NamespaceResolver, runtime: RuntimeConfig) -> Self {
337 Self::from_parts_with_namespace_store(
342 namespace_resolver,
343 runtime,
344 Arc::new(aion_store::InMemoryStore::default()),
345 )
346 }
347
348 #[must_use]
355 pub fn from_parts_with_namespace_store<S>(
356 namespace_resolver: NamespaceResolver,
357 runtime: RuntimeConfig,
358 store: Arc<S>,
359 ) -> Self
360 where
361 S: NamespaceStore + WorkerDeploymentStore,
362 {
363 let namespace_store: Arc<dyn NamespaceStore> = store.clone();
364 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = store;
365 Self::from_parts_with_control_stores(
366 namespace_resolver,
367 runtime,
368 namespace_store,
369 worker_deployment_store,
370 )
371 }
372
373 #[must_use]
378 pub fn from_parts_with_control_stores(
379 namespace_resolver: NamespaceResolver,
380 runtime: RuntimeConfig,
381 namespace_store: Arc<dyn NamespaceStore>,
382 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
383 ) -> Self {
384 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
385 let pending_activities =
392 PendingActivities::default().with_heartbeat_window(runtime.worker.heartbeat_window);
393 let bounds = transcript_bounds(&runtime);
397 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
398 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
399 );
400 Self {
401 inner: Arc::new(ServerStateInner {
402 namespace_guard: NamespaceGuard::new(namespace_resolver),
403 runtime,
404 worker_registry: ConnectedWorkerRegistry::default()
405 .with_worker_deployment_store(worker_deployment_store.clone())
406 .with_cluster_publisher(cluster_publisher.clone()),
407 pending_activities,
408 heartbeat_tracker,
409 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
410 drain_state: DrainState::default(),
411 metrics: None,
412 health: None,
413 activity_mock_registry: None,
414 outbox_store: None,
415 namespace_store,
416 worker_supervisor: new_supervisor(&worker_deployment_store, &cluster_publisher),
417 worker_deployment_store,
418 outbox_wake: Arc::new(tokio::sync::Notify::new()),
419 cluster_publisher,
420 transcript_publisher: build_transcript_publisher(
424 None,
425 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
426 bounds,
427 ),
428 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
429 queue_service_state: crate::worker::QueueServiceState::default(),
430 queue_declarations: crate::worker::QueueDeclarationSource::default(),
431 update_status: crate::update_check::UpdateStatusState::default(),
435 workspace_root: crate::worker::WorkspaceRoot::resolve(),
439 cluster_self_node: None,
440 #[cfg(feature = "haematite-backend")]
441 cluster_responder: None,
442 #[cfg(feature = "haematite-backend")]
443 cluster_store: None,
444 #[cfg(feature = "haematite-backend")]
445 watched_peers: Vec::new(),
446 #[cfg(feature = "haematite-backend")]
447 shard_directory: None,
448 #[cfg(feature = "haematite-backend")]
449 request_forwarder: None,
450 #[cfg(feature = "auth")]
451 jwks_cache: None,
452 }),
453 }
454 }
455
456 #[cfg(feature = "auth")]
465 #[must_use]
466 pub fn from_parts_with_namespace_store_and_jwks<S>(
467 namespace_resolver: NamespaceResolver,
468 runtime: RuntimeConfig,
469 store: Arc<S>,
470 jwks_cache: JwksCache,
471 ) -> Self
472 where
473 S: NamespaceStore + WorkerDeploymentStore,
474 {
475 let namespace_store: Arc<dyn NamespaceStore> = store.clone();
476 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = store;
477 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
478 let pending_activities =
485 PendingActivities::default().with_heartbeat_window(runtime.worker.heartbeat_window);
486 let bounds = transcript_bounds(&runtime);
490 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
491 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
492 );
493 Self {
494 inner: Arc::new(ServerStateInner {
495 namespace_guard: NamespaceGuard::new(namespace_resolver),
496 runtime,
497 worker_registry: ConnectedWorkerRegistry::default()
498 .with_worker_deployment_store(worker_deployment_store.clone())
499 .with_cluster_publisher(cluster_publisher.clone()),
500 pending_activities,
501 heartbeat_tracker,
502 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
503 drain_state: DrainState::default(),
504 metrics: None,
505 health: None,
506 activity_mock_registry: None,
507 outbox_store: None,
508 namespace_store,
509 worker_supervisor: new_supervisor(&worker_deployment_store, &cluster_publisher),
510 worker_deployment_store,
511 outbox_wake: Arc::new(tokio::sync::Notify::new()),
512 cluster_publisher,
513 transcript_publisher: build_transcript_publisher(
517 None,
518 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
519 bounds,
520 ),
521 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
522 queue_service_state: crate::worker::QueueServiceState::default(),
523 queue_declarations: crate::worker::QueueDeclarationSource::default(),
524 update_status: crate::update_check::UpdateStatusState::default(),
528 workspace_root: crate::worker::WorkspaceRoot::resolve(),
532 cluster_self_node: None,
533 #[cfg(feature = "haematite-backend")]
534 cluster_responder: None,
535 #[cfg(feature = "haematite-backend")]
536 cluster_store: None,
537 #[cfg(feature = "haematite-backend")]
538 watched_peers: Vec::new(),
539 #[cfg(feature = "haematite-backend")]
540 shard_directory: None,
541 #[cfg(feature = "haematite-backend")]
542 request_forwarder: None,
543 jwks_cache: Some(jwks_cache),
544 }),
545 }
546 }
547
548 #[cfg(feature = "auth")]
554 #[must_use]
555 pub fn from_parts_with_jwks(
556 namespace_resolver: NamespaceResolver,
557 runtime: RuntimeConfig,
558 jwks_cache: JwksCache,
559 ) -> Self {
560 let fallback_deployment_store: Arc<dyn WorkerDeploymentStore> =
564 Arc::new(aion_store::InMemoryStore::default());
565 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
566 let pending_activities =
573 PendingActivities::default().with_heartbeat_window(runtime.worker.heartbeat_window);
574 let bounds = transcript_bounds(&runtime);
578 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
579 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
580 );
581 Self {
582 inner: Arc::new(ServerStateInner {
583 namespace_guard: NamespaceGuard::new(namespace_resolver),
584 runtime,
585 worker_registry: ConnectedWorkerRegistry::default(),
586 pending_activities,
587 heartbeat_tracker,
588 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
589 drain_state: DrainState::default(),
590 metrics: None,
591 health: None,
592 activity_mock_registry: None,
593 outbox_store: None,
594 namespace_store: Arc::new(aion_store::InMemoryStore::default()),
599 worker_supervisor: new_supervisor(&fallback_deployment_store, &cluster_publisher),
600 worker_deployment_store: fallback_deployment_store,
601 outbox_wake: Arc::new(tokio::sync::Notify::new()),
602 cluster_publisher,
603 transcript_publisher: build_transcript_publisher(
607 None,
608 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
609 bounds,
610 ),
611 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
612 queue_service_state: crate::worker::QueueServiceState::default(),
613 queue_declarations: crate::worker::QueueDeclarationSource::default(),
614 update_status: crate::update_check::UpdateStatusState::default(),
618 workspace_root: crate::worker::WorkspaceRoot::resolve(),
622 cluster_self_node: None,
623 #[cfg(feature = "haematite-backend")]
624 cluster_responder: None,
625 #[cfg(feature = "haematite-backend")]
626 cluster_store: None,
627 #[cfg(feature = "haematite-backend")]
628 watched_peers: Vec::new(),
629 #[cfg(feature = "haematite-backend")]
630 shard_directory: None,
631 #[cfg(feature = "haematite-backend")]
632 request_forwarder: None,
633 jwks_cache: Some(jwks_cache),
634 }),
635 }
636 }
637
638 #[must_use]
640 pub fn from_parts_with_registry(
641 namespace_resolver: NamespaceResolver,
642 runtime: RuntimeConfig,
643 worker_registry: ConnectedWorkerRegistry,
644 ) -> Self {
645 let fallback_deployment_store: Arc<dyn WorkerDeploymentStore> =
649 Arc::new(aion_store::InMemoryStore::default());
650 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
651 let pending_activities =
658 PendingActivities::default().with_heartbeat_window(runtime.worker.heartbeat_window);
659 let bounds = transcript_bounds(&runtime);
663 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
664 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
665 );
666 Self {
667 inner: Arc::new(ServerStateInner {
668 namespace_guard: NamespaceGuard::new(namespace_resolver),
669 runtime,
670 worker_registry,
671 pending_activities,
672 heartbeat_tracker,
673 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
674 drain_state: DrainState::default(),
675 metrics: None,
676 health: None,
677 activity_mock_registry: None,
678 outbox_store: None,
679 namespace_store: Arc::new(aion_store::InMemoryStore::default()),
684 worker_supervisor: new_supervisor(&fallback_deployment_store, &cluster_publisher),
685 worker_deployment_store: fallback_deployment_store,
686 outbox_wake: Arc::new(tokio::sync::Notify::new()),
687 cluster_publisher,
688 transcript_publisher: build_transcript_publisher(
692 None,
693 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
694 bounds,
695 ),
696 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
697 queue_service_state: crate::worker::QueueServiceState::default(),
698 queue_declarations: crate::worker::QueueDeclarationSource::default(),
699 update_status: crate::update_check::UpdateStatusState::default(),
703 workspace_root: crate::worker::WorkspaceRoot::resolve(),
707 cluster_self_node: None,
708 #[cfg(feature = "haematite-backend")]
709 cluster_responder: None,
710 #[cfg(feature = "haematite-backend")]
711 cluster_store: None,
712 #[cfg(feature = "haematite-backend")]
713 watched_peers: Vec::new(),
714 #[cfg(feature = "haematite-backend")]
715 shard_directory: None,
716 #[cfg(feature = "haematite-backend")]
717 request_forwarder: None,
718 #[cfg(feature = "auth")]
719 jwks_cache: None,
720 }),
721 }
722 }
723
724 #[must_use]
726 pub fn namespace_guard(&self) -> &NamespaceGuard {
727 &self.inner.namespace_guard
728 }
729
730 #[must_use]
732 pub fn deploy_guard(&self) -> crate::deploy::DeployGuard {
733 crate::deploy::DeployGuard::new(self.inner.namespace_guard.resolver().clone())
734 }
735
736 #[must_use]
738 pub fn runtime_config(&self) -> &RuntimeConfig {
739 &self.inner.runtime
740 }
741
742 #[must_use]
750 pub(crate) fn update_status(&self) -> &crate::update_check::UpdateStatusState {
751 &self.inner.update_status
752 }
753
754 #[must_use]
764 pub fn workspace_root(&self) -> &crate::worker::WorkspaceRoot {
765 &self.inner.workspace_root
766 }
767
768 #[must_use]
770 pub fn worker_registry(&self) -> &ConnectedWorkerRegistry {
771 &self.inner.worker_registry
772 }
773
774 #[must_use]
778 pub fn cluster_publisher(&self) -> &crate::cluster_publisher::ClusterEventPublisher {
779 &self.inner.cluster_publisher
780 }
781
782 #[must_use]
787 pub fn transcript_publisher(&self) -> &crate::activity_publisher::ActivityEventPublisher {
788 &self.inner.transcript_publisher
789 }
790
791 #[must_use]
795 pub fn attempt_owners(&self) -> &crate::worker::AttemptOwnerIndex {
796 &self.inner.attempt_owners
797 }
798
799 #[must_use]
802 pub fn queue_service_state(&self) -> &crate::worker::QueueServiceState {
803 &self.inner.queue_service_state
804 }
805
806 #[must_use]
810 pub fn queue_declarations(&self) -> &crate::worker::QueueDeclarationSource {
811 &self.inner.queue_declarations
812 }
813
814 pub fn unserved_queues(&self) -> Result<Vec<crate::worker::UnservedQueue>, ServerError> {
825 self.inner.queue_service_state.unserved()
826 }
827
828 pub fn unrecoverable_runs(
846 &self,
847 ) -> Result<Vec<(aion_core::WorkflowId, aion::registry::UnrecoverableRun)>, ServerError> {
848 self.engine()?
849 .registry()
850 .unrecoverable()
851 .list()
852 .map_err(ServerError::from)
853 }
854
855 #[must_use]
867 pub fn intervention_router(&self) -> crate::worker::InterventionRouter {
868 let transport: std::sync::Arc<dyn crate::worker::InterventionTransport> = {
869 #[cfg(feature = "liminal-transport")]
870 {
871 std::sync::Arc::new(crate::worker::LiminalInterventionTransport)
872 }
873 #[cfg(not(feature = "liminal-transport"))]
874 {
875 std::sync::Arc::new(NullInterventionTransport)
876 }
877 };
878 crate::worker::InterventionRouter::new(
879 self.inner.worker_registry.clone(),
880 self.inner.attempt_owners.clone(),
881 transport,
882 )
883 .with_transcript_publisher(self.inner.transcript_publisher.clone())
886 }
887
888 #[must_use]
892 pub fn cluster_self_node(&self) -> Option<&str> {
893 self.inner.cluster_self_node.as_deref()
894 }
895
896 pub fn engine(&self) -> Result<Arc<aion::Engine>, ServerError> {
909 self.inner
910 .namespace_guard
911 .resolver()
912 .engine()
913 .map(Arc::clone)
914 }
915
916 #[must_use]
918 pub fn pending_activities(&self) -> &PendingActivities {
919 &self.inner.pending_activities
920 }
921
922 #[must_use]
924 pub fn heartbeat_tracker(&self) -> &HeartbeatTracker {
925 &self.inner.heartbeat_tracker
926 }
927
928 #[must_use]
934 pub fn grpc_liveness_waiters(&self) -> &crate::worker::GrpcLivenessWaiters {
935 &self.inner.grpc_liveness_waiters
936 }
937
938 #[must_use]
940 pub fn drain_state(&self) -> &DrainState {
941 &self.inner.drain_state
942 }
943
944 #[must_use]
946 pub fn metrics(&self) -> Option<&Metrics> {
947 self.inner.metrics.as_ref()
948 }
949
950 #[must_use]
952 pub fn health(&self) -> Option<&HealthState> {
953 self.inner.health.as_ref()
954 }
955
956 #[must_use]
961 pub fn activity_mock_registry(&self) -> Option<&ActivityMockRegistry> {
962 self.inner.activity_mock_registry.as_ref()
963 }
964
965 #[must_use]
971 pub fn outbox_store(&self) -> Option<Arc<dyn OutboxStore>> {
972 self.inner.outbox_store.clone()
973 }
974
975 #[must_use]
984 pub fn namespace_store(&self) -> &Arc<dyn NamespaceStore> {
985 &self.inner.namespace_store
986 }
987
988 #[must_use]
990 pub fn worker_deployment_store(&self) -> &Arc<dyn WorkerDeploymentStore> {
991 &self.inner.worker_deployment_store
992 }
993
994 #[must_use]
1001 pub fn worker_supervisor(&self) -> &Arc<WorkerSupervisor> {
1002 &self.inner.worker_supervisor
1003 }
1004
1005 #[must_use]
1014 pub fn namespace_minter(&self) -> NamespaceMinter {
1015 let minter = NamespaceMinter::new(
1016 Arc::clone(&self.inner.namespace_store),
1017 self.inner.runtime.auto_create,
1018 )
1019 .with_cluster_publisher(self.inner.cluster_publisher.clone());
1024 match self.namespace_routing() {
1029 Some(routing) => minter.with_routing(routing),
1030 None => minter,
1031 }
1032 }
1033
1034 #[cfg(feature = "haematite-backend")]
1044 #[must_use]
1045 pub fn namespace_routing(&self) -> Option<crate::namespace::NamespaceRouting> {
1046 build_namespace_routing(
1047 self.cluster_store(),
1048 self.shard_directory(),
1049 self.request_forwarder(),
1050 )
1051 }
1052
1053 #[cfg(not(feature = "haematite-backend"))]
1055 #[must_use]
1056 pub const fn namespace_routing(&self) -> Option<crate::namespace::NamespaceRouting> {
1057 None
1058 }
1059
1060 #[must_use]
1066 pub fn outbox_wake(&self) -> Arc<tokio::sync::Notify> {
1067 Arc::clone(&self.inner.outbox_wake)
1068 }
1069
1070 #[cfg(feature = "haematite-backend")]
1076 #[must_use]
1077 pub fn is_clustered(&self) -> bool {
1078 self.inner.cluster_responder.is_some()
1079 }
1080
1081 #[cfg(feature = "haematite-backend")]
1086 #[must_use]
1087 pub fn cluster_store(&self) -> Option<&Arc<aion_store_haematite::HaematiteStore>> {
1088 self.inner.cluster_store.as_ref()
1089 }
1090
1091 #[cfg(feature = "haematite-backend")]
1095 #[must_use]
1096 pub fn shard_directory(&self) -> Option<&Arc<crate::routing::StaticShardDirectory>> {
1097 self.inner.shard_directory.as_ref()
1098 }
1099
1100 #[cfg(feature = "haematite-backend")]
1103 #[must_use]
1104 pub fn request_forwarder(&self) -> Option<&Arc<dyn crate::routing::RequestForwarder>> {
1105 self.inner.request_forwarder.as_ref()
1106 }
1107
1108 #[must_use]
1125 pub fn spawn_heartbeat_sweeper(
1126 &self,
1127 shutdown: tokio::sync::watch::Receiver<bool>,
1128 ) -> tokio::task::JoinHandle<()> {
1129 let sweeper = crate::worker::HeartbeatSweeper::new(
1130 self.inner.heartbeat_tracker.clone(),
1131 self.inner.worker_registry.clone(),
1132 self.inner.pending_activities.clone(),
1133 self.inner.drain_state.clone(),
1134 self.inner.runtime.worker.heartbeat_window,
1135 )
1136 .with_queue_state(self.inner.queue_service_state.clone());
1140 tokio::spawn(sweeper.run(shutdown))
1141 }
1142
1143 #[cfg(feature = "liminal-transport")]
1154 #[must_use]
1155 pub fn spawn_liminal_liveness_probe(
1156 &self,
1157 notifier: std::sync::Arc<crate::worker::LiminalConnectionNotifier>,
1158 shutdown: tokio::sync::watch::Receiver<bool>,
1159 ) -> tokio::task::JoinHandle<()> {
1160 let probe = crate::worker::LivenessProbe::across_transports(
1161 Some(notifier),
1162 Some(self.inner.grpc_liveness_waiters.clone()),
1167 self.inner.heartbeat_tracker.clone(),
1168 self.inner.worker_registry.clone(),
1169 self.inner.runtime.worker.heartbeat_window,
1170 );
1171 tokio::spawn(probe.run(shutdown))
1172 }
1173
1174 #[cfg(feature = "haematite-backend")]
1190 pub fn spawn_cluster_supervisor(
1191 &self,
1192 config: crate::cluster::SupervisorConfig,
1193 shutdown: tokio::sync::watch::Receiver<bool>,
1194 ) -> Result<bool, ServerError> {
1195 let Some(cluster_store) = self.inner.cluster_store.clone() else {
1196 return Ok(false);
1197 };
1198 if self.inner.watched_peers.is_empty() {
1199 return Ok(false);
1200 }
1201 let engine = Arc::clone(self.inner.namespace_guard.resolver().engine()?);
1202 let publisher = Arc::new(self.inner.cluster_publisher.clone());
1206 let self_node = self.inner.cluster_self_node.clone().unwrap_or_default();
1207 let adopter = Arc::new(crate::cluster::OutboxSettlingAdopter::new(
1213 engine,
1214 self.inner.outbox_store.clone(),
1215 ));
1216 let supervisor = crate::cluster::ClusterSupervisor::new(
1217 cluster_store,
1218 adopter,
1219 self.inner.watched_peers.clone(),
1220 config,
1221 )
1222 .with_publisher(publisher, self_node);
1223 if !supervisor.watches_any() {
1224 return Ok(false);
1225 }
1226 tokio::spawn(supervisor.run(shutdown));
1227 Ok(true)
1228 }
1229
1230 #[cfg(feature = "auth")]
1232 #[must_use]
1233 pub fn jwks_cache(&self) -> Option<&JwksCache> {
1234 self.inner.jwks_cache.as_ref()
1235 }
1236
1237 pub fn shutdown(&self) -> Result<(), ServerError> {
1244 self.inner.namespace_guard.resolver().shutdown_engine()
1245 }
1246}
1247
1248#[cfg(feature = "auth")]
1249async fn build_jwks_cache(runtime: &RuntimeConfig) -> Result<Option<JwksCache>, ServerError> {
1250 if !runtime.auth.enabled {
1251 return Ok(None);
1252 }
1253 let Some(url) = runtime.auth.jwks_url.clone() else {
1254 return Err(ServerError::Config {
1255 message: "auth.jwks_url must not be empty when auth.enabled is true".to_owned(),
1256 });
1257 };
1258 let interval = std::time::Duration::from_secs(runtime.auth.jwks_refresh_seconds);
1259 let cache = JwksCache::new(url, interval)
1260 .await
1261 .map_err(|error| ServerError::Config {
1262 message: format!("auth jwks initial fetch failed: {error}"),
1263 })?;
1264 Ok(Some(cache))
1265}
1266
1267fn metrics_config_error(error: &MetricsError) -> ServerError {
1268 ServerError::Config {
1269 message: error.to_string(),
1270 }
1271}
1272
1273struct EngineAssembly<'a> {
1275 instrumented_store: &'a Arc<InstrumentedEventStore>,
1277 event_broadcast_capacity: std::num::NonZeroUsize,
1279 query_timeout: std::time::Duration,
1281 activity_dispatcher: Arc<dyn ActivityDispatcher>,
1283 active_registry: Arc<aion::Registry>,
1285 bootstrap_coordinator: bool,
1287 runtime: &'a RuntimeConfig,
1289}
1290
1291async fn build_engine(assembly: EngineAssembly<'_>) -> Result<aion::Engine, ServerError> {
1298 let mut search_attribute_schema = aion_core::SearchAttributeSchema::new();
1299 search_attribute_schema
1300 .register(
1301 crate::namespace::NAMESPACE_ATTRIBUTE,
1302 aion_core::SearchAttributeType::String,
1303 )
1304 .map_err(|error| ServerError::Config {
1305 message: format!("failed to register namespace search attribute: {error}"),
1306 })?;
1307 search_attribute_schema
1308 .register(
1309 crate::namespace::TASK_QUEUE_ATTRIBUTE,
1310 aion_core::SearchAttributeType::String,
1311 )
1312 .map_err(|error| ServerError::Config {
1313 message: format!("failed to register task_queue search attribute: {error}"),
1314 })?;
1315 let runtime = assembly.runtime;
1316 let builder = EngineBuilder::new()
1317 .store_arc(assembly.instrumented_store.clone())
1318 .event_streaming(assembly.event_broadcast_capacity)
1319 .in_memory_visibility()
1320 .search_attribute_schema(search_attribute_schema)
1321 .scheduler_threads(runtime.scheduler_threads)
1322 .outbox_enabled(runtime.outbox.enabled)
1323 .activity_dispatcher(assembly.activity_dispatcher)
1324 .active_registry(assembly.active_registry)
1325 .production_recovery_seam()
1326 .signal_router_factory(|runtime: Arc<RuntimeHandle>, handoff| {
1327 Arc::new(ConcreteSignalRouter::new(runtime, handoff)) as Arc<dyn SignalRouter>
1328 })
1329 .query_timeout(assembly.query_timeout)
1330 .bootstrap_schedule_coordinator(assembly.bootstrap_coordinator)
1336 .load_workflow_sources(runtime.workflow_packages.iter().map(PathBuf::as_path));
1337 let builder = if runtime.owned_shards.is_empty() {
1343 builder
1344 } else {
1345 builder.owned_shards(runtime.owned_shards.iter().copied())
1346 };
1347 builder.build().await.map_err(ServerError::from)
1348}
1349
1350fn required_engine_seams(
1355 runtime: &RuntimeConfig,
1356) -> Result<(std::num::NonZeroUsize, std::time::Duration), ServerError> {
1357 let event_broadcast_capacity = runtime
1358 .websocket
1359 .event_broadcast_capacity
1360 .and_then(std::num::NonZeroUsize::new)
1361 .ok_or_else(|| ServerError::Config {
1362 message: crate::config::EVENT_BROADCAST_CAPACITY_REQUIRED.to_owned(),
1363 })?;
1364 let query_timeout = runtime
1365 .query_timeout
1366 .filter(|timeout| !timeout.is_zero())
1367 .ok_or_else(|| ServerError::Config {
1368 message: crate::config::QUERY_TIMEOUT_REQUIRED.to_owned(),
1369 })?;
1370 Ok((event_broadcast_capacity, query_timeout))
1371}
1372
1373fn install_outbox_delivery(
1380 pending_activities: &PendingActivities,
1381 engine: &Arc<aion::Engine>,
1382 outbox_enabled: bool,
1383) {
1384 if outbox_enabled {
1385 let callback = Arc::new(crate::worker::ServerOutboxDeliveryCallback::new(
1386 Arc::clone(engine),
1387 ));
1388 pending_activities.set_outbox_delivery(callback);
1389 }
1390}
1391
1392struct WorkerSeams {
1394 worker_registry: ConnectedWorkerRegistry,
1395 pending_activities: PendingActivities,
1396 heartbeat_tracker: HeartbeatTracker,
1397 drain_state: DrainState,
1398 queue_declarations: crate::worker::QueueDeclarationSource,
1399 queue_service_state: crate::worker::QueueServiceState,
1400 declared_bodies: crate::worker::DeclaredBodySource,
1401}
1402
1403fn build_worker_seams(
1408 runtime: &RuntimeConfig,
1409 cluster_publisher: &crate::cluster_publisher::ClusterEventPublisher,
1410 namespace_store: &Arc<dyn NamespaceStore>,
1411 worker_deployment_store: &Arc<dyn WorkerDeploymentStore>,
1412 mint_routing: Option<crate::namespace::NamespaceRouting>,
1413) -> WorkerSeams {
1414 let worker_registry = ConnectedWorkerRegistry::default()
1415 .with_cluster_publisher(cluster_publisher.clone())
1416 .with_namespace_minting(namespace_store.clone(), runtime.auto_create)
1417 .with_worker_deployment_store(worker_deployment_store.clone());
1418 let worker_registry = match mint_routing {
1424 Some(routing) => worker_registry.with_namespace_routing(routing),
1425 None => worker_registry,
1426 };
1427 WorkerSeams {
1428 worker_registry,
1429 pending_activities: PendingActivities::default()
1434 .with_heartbeat_window(runtime.worker.heartbeat_window),
1435 heartbeat_tracker: HeartbeatTracker::new(runtime.worker.heartbeat_window),
1436 drain_state: DrainState::default(),
1437 queue_declarations: crate::worker::QueueDeclarationSource::default(),
1438 queue_service_state: crate::worker::QueueServiceState::default(),
1439 declared_bodies: crate::worker::DeclaredBodySource::default(),
1440 }
1441}
1442
1443fn install_queue_declarations(
1450 queue_declarations: &crate::worker::QueueDeclarationSource,
1451 engine: &Arc<aion::Engine>,
1452) {
1453 queue_declarations.install(Arc::new(crate::worker::EngineQueueDeclarations::new(
1454 Arc::clone(engine),
1455 )));
1456}
1457
1458fn install_declared_bodies(
1466 declared_bodies: &crate::worker::DeclaredBodySource,
1467 engine: &Arc<aion::Engine>,
1468) {
1469 declared_bodies.install(Arc::new(crate::worker::EngineDeclaredBodies::new(
1470 Arc::clone(engine),
1471 )));
1472}
1473
1474fn install_engine_backed_seams(seams: &WorkerSeams, engine: &Arc<aion::Engine>, outbox: bool) {
1480 install_outbox_delivery(&seams.pending_activities, engine, outbox);
1481 install_queue_declarations(&seams.queue_declarations, engine);
1482 install_declared_bodies(&seams.declared_bodies, engine);
1483}
1484
1485fn build_bridge_dispatcher(
1498 runtime: &RuntimeConfig,
1499 seams: &WorkerSeams,
1500) -> (WorkerActivityDispatcher, crate::worker::AttemptOwnerIndex) {
1501 let attempt_owners = crate::worker::AttemptOwnerIndex::new();
1502 let dispatcher = WorkerActivityDispatcher::new(
1503 seams.worker_registry.clone(),
1504 runtime.default_namespace.clone(),
1505 seams.heartbeat_tracker.clone(),
1506 )
1507 .with_pending(seams.pending_activities.clone())
1508 .with_drain_state(seams.drain_state.clone())
1509 .with_tokio_handle(tokio::runtime::Handle::current())
1510 .with_attempt_owners(attempt_owners.clone())
1511 .with_queue_service(runtime.worker.queue_service.clone())
1512 .with_queue_declarations(seams.queue_declarations.clone())
1513 .with_queue_state(seams.queue_service_state.clone());
1514 (dispatcher, attempt_owners)
1515}
1516
1517fn build_instrumented_store(
1530 runtime: &RuntimeConfig,
1531 event_store: Arc<dyn EventStore>,
1532) -> Result<
1533 (
1534 Metrics,
1535 Arc<tokio::sync::Notify>,
1536 Arc<InstrumentedEventStore>,
1537 ),
1538 ServerError,
1539> {
1540 let metrics = Metrics::new().map_err(|error| metrics_config_error(&error))?;
1541 let outbox_wake = Arc::new(tokio::sync::Notify::new());
1542 let instrumented_store = Arc::new(
1543 InstrumentedEventStore::new(
1544 event_store,
1545 metrics.clone(),
1546 runtime.default_namespace.clone(),
1547 )
1548 .with_outbox_wake(Arc::clone(&outbox_wake)),
1549 );
1550 Ok((metrics, outbox_wake, instrumented_store))
1551}
1552
1553fn build_decorated_dispatcher(
1558 runtime: &RuntimeConfig,
1559 seams: &WorkerSeams,
1560 transcript: crate::activity_publisher::ActivityEventPublisher,
1561) -> (
1562 Arc<dyn ActivityDispatcher>,
1563 Option<ActivityMockRegistry>,
1564 crate::worker::AttemptOwnerIndex,
1565 crate::worker::WorkspaceRoot,
1566 crate::update_check::UpdateStatusState,
1567) {
1568 let workspace_root = crate::worker::WorkspaceRoot::resolve();
1572 let update_status = crate::update_check::UpdateStatusState::default();
1575 let (dispatcher, attempt_owners) = build_bridge_dispatcher(runtime, seams);
1576 let (activity_dispatcher, activity_mock_registry) = decorate_activity_dispatcher(
1577 dispatcher,
1578 seams.declared_bodies.clone(),
1579 workspace_root.clone(),
1580 transcript,
1581 update_status.clone(),
1582 runtime.dev.enabled,
1583 );
1584 (
1585 activity_dispatcher,
1586 activity_mock_registry,
1587 attempt_owners,
1588 workspace_root,
1589 update_status,
1590 )
1591}
1592
1593fn decorate_activity_dispatcher(
1594 dispatcher: WorkerActivityDispatcher,
1595 declared_bodies: crate::worker::DeclaredBodySource,
1596 workspace_root: crate::worker::WorkspaceRoot,
1597 transcript: crate::activity_publisher::ActivityEventPublisher,
1598 update_status: crate::update_check::UpdateStatusState,
1599 dev_enabled: bool,
1600) -> (Arc<dyn ActivityDispatcher>, Option<ActivityMockRegistry>) {
1601 let declared = crate::worker::DeclaredCommandDispatcher::new(
1609 Arc::new(dispatcher),
1610 declared_bodies.clone(),
1611 tokio::runtime::Handle::current(),
1612 workspace_root,
1613 transcript,
1614 );
1615 let observed = crate::update_check::UpdateCheckObserver::new(
1616 Arc::new(declared),
1617 declared_bodies,
1618 update_status,
1619 );
1620 if dev_enabled {
1621 let registry = ActivityMockRegistry::new();
1622 let decorated = DevMockingDispatcher::new(Arc::new(observed), registry.clone());
1623 (Arc::new(decorated), Some(registry))
1624 } else {
1625 (Arc::new(observed), None)
1626 }
1627}
1628
1629fn required_cluster_broadcast_capacity(
1634 runtime: &RuntimeConfig,
1635) -> Result<std::num::NonZeroUsize, ServerError> {
1636 runtime
1637 .websocket
1638 .cluster_broadcast_capacity
1639 .and_then(std::num::NonZeroUsize::new)
1640 .ok_or_else(|| ServerError::Config {
1641 message: crate::config::CLUSTER_BROADCAST_CAPACITY_REQUIRED.to_owned(),
1642 })
1643}
1644
1645fn build_real_time_publishers(
1658 runtime: &RuntimeConfig,
1659 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1660) -> Result<
1661 (
1662 crate::cluster_publisher::ClusterEventPublisher,
1663 crate::activity_publisher::ActivityEventPublisher,
1664 ),
1665 ServerError,
1666> {
1667 let capacity = required_cluster_broadcast_capacity(runtime)?;
1668 Ok((
1669 crate::cluster_publisher::ClusterEventPublisher::new(capacity),
1670 build_transcript_publisher(observability_store, capacity, transcript_bounds(runtime)),
1671 ))
1672}
1673
1674fn transcript_bounds(runtime: &RuntimeConfig) -> crate::activity_bounds::TranscriptBounds {
1676 crate::activity_bounds::TranscriptBounds {
1677 max_event_bytes: runtime.observability.max_event_bytes,
1678 max_stream_events: runtime.observability.max_stream_events,
1679 }
1680}
1681
1682fn build_transcript_publisher(
1693 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1694 capacity: std::num::NonZeroUsize,
1695 bounds: crate::activity_bounds::TranscriptBounds,
1696) -> crate::activity_publisher::ActivityEventPublisher {
1697 let store = observability_store
1698 .unwrap_or_else(|| Arc::new(aion_store::InMemoryObservabilityStore::default()));
1699 crate::activity_publisher::ActivityEventPublisher::new(store, capacity).with_bounds(bounds)
1700}
1701
1702#[cfg(feature = "haematite-backend")]
1704struct RoutingState {
1705 shard_directory: Option<Arc<crate::routing::StaticShardDirectory>>,
1706 request_forwarder: Option<Arc<dyn crate::routing::RequestForwarder>>,
1707 mint_routing: Option<crate::namespace::NamespaceRouting>,
1712}
1713
1714#[cfg(feature = "haematite-backend")]
1718fn build_routing_state(
1719 cluster_store: Option<&Arc<aion_store_haematite::HaematiteStore>>,
1720 directory_peers: Vec<crate::routing::DirectoryPeer>,
1721 self_node_id: Option<String>,
1722) -> RoutingState {
1723 let Some(store) = cluster_store else {
1724 return RoutingState {
1725 shard_directory: None,
1726 request_forwarder: None,
1727 mint_routing: None,
1728 };
1729 };
1730 let shard_directory = Arc::new(crate::routing::StaticShardDirectory::new(
1731 Arc::clone(store),
1732 directory_peers,
1733 self_node_id,
1734 ));
1735 let request_forwarder: Arc<dyn crate::routing::RequestForwarder> =
1736 Arc::new(crate::routing::GrpcRequestForwarder::new());
1737 let mint_routing = build_namespace_routing(
1738 Some(store),
1739 Some(&shard_directory),
1740 Some(&request_forwarder),
1741 );
1742 RoutingState {
1743 shard_directory: Some(shard_directory),
1744 request_forwarder: Some(request_forwarder),
1745 mint_routing,
1746 }
1747}
1748
1749#[cfg(feature = "haematite-backend")]
1759fn build_namespace_routing(
1760 cluster_store: Option<&Arc<aion_store_haematite::HaematiteStore>>,
1761 shard_directory: Option<&Arc<crate::routing::StaticShardDirectory>>,
1762 request_forwarder: Option<&Arc<dyn crate::routing::RequestForwarder>>,
1763) -> Option<crate::namespace::NamespaceRouting> {
1764 use crate::namespace::{
1765 GrpcMintForwarder, MintForwarder, MintShardOwners, NamespaceRouting, NamespaceShardResolver,
1766 };
1767 let store = Arc::clone(cluster_store?);
1768 let directory = Arc::clone(shard_directory?);
1769 let shards: Arc<dyn NamespaceShardResolver> = store;
1770 let owners: Arc<dyn MintShardOwners> = directory;
1771 let forwarder: Arc<dyn MintForwarder> =
1772 Arc::new(GrpcMintForwarder::new(Arc::clone(request_forwarder?)));
1773 Some(NamespaceRouting::new(shards, owners, forwarder))
1774}
1775
1776struct ConnectedStore {
1786 event_store: Arc<dyn EventStore>,
1787 outbox_store: Option<Arc<dyn OutboxStore>>,
1788 namespace_store: Arc<dyn NamespaceStore>,
1795 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
1797 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1805 bootstrap_coordinator: bool,
1806 #[cfg(feature = "haematite-backend")]
1807 cluster_responder: Option<aion_store_haematite::ClusterResponder>,
1808 #[cfg(feature = "haematite-backend")]
1812 cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
1813 #[cfg(feature = "haematite-backend")]
1816 watched_peers: Vec<crate::cluster::WatchedPeer>,
1817 #[cfg(feature = "haematite-backend")]
1821 directory_peers: Vec<crate::routing::DirectoryPeer>,
1822 #[cfg(feature = "haematite-backend")]
1826 self_node_id: Option<String>,
1827}
1828
1829impl ConnectedStore {
1830 fn local(
1837 event_store: Arc<dyn EventStore>,
1838 outbox_store: Option<Arc<dyn OutboxStore>>,
1839 namespace_store: Arc<dyn NamespaceStore>,
1840 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
1841 ) -> Self {
1842 Self {
1843 event_store,
1844 outbox_store,
1845 namespace_store,
1846 worker_deployment_store,
1847 observability_store: None,
1851 bootstrap_coordinator: true,
1852 #[cfg(feature = "haematite-backend")]
1853 cluster_responder: None,
1854 #[cfg(feature = "haematite-backend")]
1855 cluster_store: None,
1856 #[cfg(feature = "haematite-backend")]
1857 watched_peers: Vec::new(),
1858 #[cfg(feature = "haematite-backend")]
1859 directory_peers: Vec::new(),
1860 #[cfg(feature = "haematite-backend")]
1861 self_node_id: None,
1862 }
1863 }
1864
1865 #[cfg(feature = "haematite-backend")]
1868 fn cluster_self_node(&self) -> Option<String> {
1869 self.self_node_id.clone()
1870 }
1871
1872 #[cfg(not(feature = "haematite-backend"))]
1874 const fn cluster_self_node(&self) -> Option<String> {
1875 None
1876 }
1877}
1878
1879async fn connect_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
1889 match config.backend {
1890 StoreBackend::Memory => {
1891 let leaf = Arc::new(aion_store::InMemoryStore::default());
1894 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
1895 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
1896 Ok(ConnectedStore::local(
1897 leaf,
1898 None,
1899 namespace_store,
1900 worker_deployment_store,
1901 ))
1902 }
1903 StoreBackend::LibSql => {
1904 #[cfg(feature = "libsql-backend")]
1905 {
1906 connect_libsql_store(config).await
1907 }
1908 #[cfg(not(feature = "libsql-backend"))]
1909 {
1910 let _ = config;
1911 connect_libsql_store_unavailable()
1912 }
1913 }
1914 StoreBackend::Haematite => {
1915 #[cfg(feature = "haematite-backend")]
1916 {
1917 connect_haematite_store(config).await
1918 }
1919 #[cfg(not(feature = "haematite-backend"))]
1920 {
1921 let _ = config;
1922 connect_haematite_store_unavailable()
1923 }
1924 }
1925 }
1926}
1927
1928#[cfg(feature = "libsql-backend")]
1932async fn connect_libsql_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
1933 let Some(url) = config.url else {
1934 return Err(ServerError::Config {
1935 message: "store.url must not be empty when store.backend is libsql".to_owned(),
1936 });
1937 };
1938 let store = LibSqlStore::open(url.clone())
1939 .await
1940 .map_err(ServerError::from)?;
1941 store
1942 .validate_event_compatibility()
1943 .await
1944 .map_err(|error| match error {
1945 aion_store::StoreError::Serialization(_) => ServerError::Config {
1946 message: format!(
1947 "Database schema mismatch — delete {url} and restart, or run migrations."
1948 ),
1949 },
1950 other => ServerError::from(other),
1951 })?;
1952 let leaf = Arc::new(store);
1953 let event_store: Arc<dyn EventStore> = leaf.clone();
1954 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
1955 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
1956 let outbox_store: Arc<dyn OutboxStore> = leaf;
1957 Ok(ConnectedStore::local(
1958 event_store,
1959 Some(outbox_store),
1960 namespace_store,
1961 worker_deployment_store,
1962 ))
1963}
1964
1965#[cfg(not(feature = "libsql-backend"))]
1969fn connect_libsql_store_unavailable() -> Result<ConnectedStore, ServerError> {
1970 Err(ServerError::Config {
1971 message: "store.backend = libsql requires the aion-server `libsql-backend` feature"
1972 .to_owned(),
1973 })
1974}
1975
1976#[cfg(feature = "haematite-backend")]
1996async fn connect_haematite_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
1997 let Some(data_dir) = config.data_dir else {
1998 return Err(ServerError::Config {
1999 message: "store.data_dir must not be empty when store.backend is haematite".to_owned(),
2000 });
2001 };
2002 let shard_count = config.shard_count;
2003 let owned_shards = config.owned_shards.clone();
2004 let cluster = config.cluster.clone();
2005 let watched_peers: Vec<crate::cluster::WatchedPeer> = cluster
2010 .as_ref()
2011 .map(|cluster| {
2012 cluster
2013 .peers
2014 .iter()
2015 .map(|peer| crate::cluster::WatchedPeer {
2016 name: peer.name.clone(),
2017 owned_shards: peer.owned_shards.clone(),
2018 })
2019 .collect()
2020 })
2021 .unwrap_or_default();
2022 let directory_peers: Vec<crate::routing::DirectoryPeer> = cluster
2025 .as_ref()
2026 .map(|cluster| {
2027 cluster
2028 .peers
2029 .iter()
2030 .map(|peer| crate::routing::DirectoryPeer {
2031 name: peer.name.clone(),
2032 owned_shards: peer.owned_shards.clone(),
2033 grpc_addr: peer.grpc_address,
2034 })
2035 .collect()
2036 })
2037 .unwrap_or_default();
2038 let self_node_id: Option<String> = cluster.as_ref().map(|cluster| cluster.node_id.clone());
2041 let (store, responder) =
2045 tokio::task::spawn_blocking(move || build_haematite_store(&data_dir, shard_count, cluster))
2046 .await
2047 .map_err(|error| ServerError::Config {
2048 message: format!("haematite store initialization task failed: {error}"),
2049 })??;
2050
2051 let bootstrap_coordinator = if owned_shards.is_empty() {
2055 true
2056 } else {
2057 store.set_owned_shards(owned_shards.iter().copied());
2058 store.owns_workflow_shard(&aion::schedule_coordinator_workflow_id())
2059 };
2060
2061 let leaf = Arc::new(store);
2062 let event_store: Arc<dyn EventStore> = leaf.clone();
2063 let outbox_store: Arc<dyn OutboxStore> = leaf.clone();
2064 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
2068 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
2069 let observability_store: Arc<dyn aion_store::ObservabilityStore> = leaf.clone();
2074 let cluster_store = responder.as_ref().map(|_| leaf);
2078 let (watched_peers, directory_peers, self_node_id) = if cluster_store.is_some() {
2079 (watched_peers, directory_peers, self_node_id)
2080 } else {
2081 (Vec::new(), Vec::new(), None)
2082 };
2083 Ok(ConnectedStore {
2084 event_store,
2085 outbox_store: Some(outbox_store),
2086 namespace_store,
2087 worker_deployment_store,
2088 observability_store: Some(observability_store),
2089 bootstrap_coordinator,
2090 cluster_responder: responder,
2091 cluster_store,
2092 watched_peers,
2093 directory_peers,
2094 self_node_id,
2095 })
2096}
2097
2098#[cfg(feature = "haematite-backend")]
2113fn build_haematite_store(
2114 data_dir: &str,
2115 shard_count: usize,
2116 cluster: Option<crate::config::ClusterConfig>,
2117) -> Result<
2118 (
2119 aion_store_haematite::HaematiteStore,
2120 Option<aion_store_haematite::ClusterResponder>,
2121 ),
2122 ServerError,
2123> {
2124 build_haematite_store_with_hook(data_dir, shard_count, cluster, || Ok(()))
2125}
2126
2127#[cfg(feature = "haematite-backend")]
2128fn build_haematite_store_with_hook(
2129 data_dir: &str,
2130 shard_count: usize,
2131 cluster: Option<crate::config::ClusterConfig>,
2132 before_backend_touch: impl FnOnce() -> Result<(), std::io::Error>,
2133) -> Result<
2134 (
2135 aion_store_haematite::HaematiteStore,
2136 Option<aion_store_haematite::ClusterResponder>,
2137 ),
2138 ServerError,
2139> {
2140 use aion_store_haematite::{ClusterBootstrap, HaematiteStore};
2141
2142 let private_root = crate::filesystem::ConfinedDir::open_or_create(std::path::Path::new(
2150 data_dir,
2151 ))
2152 .map_err(|error| ServerError::Config {
2153 message: format!("unsafe store.data_dir `{data_dir}`: {error}"),
2154 })?;
2155
2156 for shard in 0..shard_count {
2160 private_root
2161 .create_dir_all(std::path::Path::new(&format!("shard-{shard}")))
2162 .map_err(|error| ServerError::Config {
2163 message: format!(
2164 "failed to materialize shard-{shard} under store.data_dir `{data_dir}`: {error}"
2165 ),
2166 })?;
2167 }
2168 private_root
2169 .harden_tree()
2170 .map_err(|error| private_store_mode_error(data_dir, &error))?;
2171
2172 before_backend_touch().map_err(|error| ServerError::Config {
2175 message: format!("store.data_dir pre-open hook failed: {error}"),
2176 })?;
2177
2178 #[cfg(unix)]
2179 let backend_path = private_root
2180 .backend_path()
2181 .map_err(|error| ServerError::Config {
2182 message: format!("failed to resolve held store.data_dir `{data_dir}`: {error}"),
2183 })?;
2184 #[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
2185 crate::filesystem::validate_ambient_backend_ancestors(&backend_path).map_err(|error| {
2186 let (component, reason) = error.into_parts();
2187 ServerError::UnsafeDataRootAncestor {
2188 data_root: backend_path.clone(),
2189 component,
2190 reason,
2191 }
2192 })?;
2193 #[cfg(not(unix))]
2194 let backend_path = std::path::PathBuf::from(data_dir);
2195
2196 let Some(cluster) = cluster else {
2197 let store = if backend_path.join("config.json").exists() {
2198 HaematiteStore::open(&backend_path).map_err(ServerError::from)?
2199 } else {
2200 HaematiteStore::create_with_shard_count(&backend_path, shard_count)
2201 .map_err(ServerError::from)?
2202 };
2203 store.materialize_all_shards().map_err(ServerError::from)?;
2204 private_root
2205 .harden_tree()
2206 .map_err(|error| private_store_mode_error(data_dir, &error))?;
2207 let store = store.retain_data_root_capability(private_root);
2208 return Ok((store, None));
2209 };
2210
2211 let boot = ClusterBootstrap {
2212 node_id: cluster.node_id,
2213 bind_address: cluster.bind_address,
2214 members: cluster.members,
2215 peers: cluster
2216 .peers
2217 .into_iter()
2218 .map(|peer| (peer.name, peer.address))
2219 .collect(),
2220 timeout: HAEMATITE_CLUSTER_OP_TIMEOUT,
2221 };
2222 let (store, responder) =
2223 HaematiteStore::open_or_create_distributed(&backend_path, shard_count, boot)
2224 .map_err(ServerError::from)?;
2225 store.materialize_all_shards().map_err(ServerError::from)?;
2226 private_root
2227 .harden_tree()
2228 .map_err(|error| private_store_mode_error(data_dir, &error))?;
2229 let store = store.retain_data_root_capability(private_root);
2230 Ok((store, Some(responder)))
2231}
2232
2233#[cfg(feature = "haematite-backend")]
2234fn private_store_mode_error(data_dir: &str, error: &std::io::Error) -> ServerError {
2235 ServerError::Config {
2236 message: format!(
2237 "failed to apply private modes under store.data_dir `{data_dir}`: {error}"
2238 ),
2239 }
2240}
2241
2242#[cfg(feature = "haematite-backend")]
2244const HAEMATITE_CLUSTER_OP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
2245
2246#[cfg(not(feature = "haematite-backend"))]
2250fn connect_haematite_store_unavailable() -> Result<ConnectedStore, ServerError> {
2251 Err(ServerError::Config {
2252 message: "store.backend = haematite requires the aion-server `haematite-backend` feature"
2253 .to_owned(),
2254 })
2255}
2256
2257#[cfg(not(feature = "liminal-transport"))]
2266#[derive(Clone, Debug)]
2267struct NullInterventionTransport;
2268
2269#[cfg(not(feature = "liminal-transport"))]
2270#[async_trait::async_trait]
2271impl crate::worker::InterventionTransport for NullInterventionTransport {
2272 async fn push(
2273 &self,
2274 _worker: &crate::worker::WorkerHandle,
2275 _command: aion_core::InterventionCommand,
2276 ) -> Result<aion_core::InterventionOutcome, ServerError> {
2277 Err(ServerError::worker_connection_lost(
2278 "intervention",
2279 "no intervention push transport is compiled in".to_owned(),
2280 ))
2281 }
2282}
2283
2284#[cfg(test)]
2285mod tests {
2286 use std::{net::SocketAddr, time::Duration};
2287
2288 use aion_store::InMemoryStore;
2289
2290 use super::ServerState;
2291 use crate::config::{
2292 AuthConfig, AuthoringConfig, DeployConfig, DevConfig, ListenConfig, MetricsConfig,
2293 NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig, OutboxConfig,
2294 RuntimeConfig, WebSocketConfig, WorkerConfig,
2295 };
2296
2297 fn runtime_config() -> RuntimeConfig {
2298 RuntimeConfig {
2299 listen: ListenConfig {
2300 grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
2301 http: SocketAddr::from(([127, 0, 0, 1], 8080)),
2302 },
2303 tls: None,
2304 auth: AuthConfig {
2305 enabled: false,
2306 jwks_url: None,
2307 jwks_refresh_seconds: 300,
2308 },
2309 ops_console: OpsConsoleConfig {
2310 source: OpsConsoleAssetSource::Embedded,
2311 },
2312 namespace: NamespaceConfig {
2313 mode: NamespaceMode::SharedEngine,
2314 },
2315 worker: WorkerConfig {
2316 heartbeat_window: Duration::from_secs(30),
2317 ..WorkerConfig::default()
2318 },
2319 websocket: WebSocketConfig {
2320 outbound_buffer_bound: 32,
2321 event_broadcast_capacity: Some(64),
2322 cluster_broadcast_capacity: Some(64),
2323 },
2324 workflow_packages: Vec::new(),
2325 deploy: DeployConfig::default(),
2326 authoring: AuthoringConfig::default(),
2327 dev: DevConfig::default(),
2328 outbox: OutboxConfig::default(),
2329 observability: crate::config::ObservabilityConfig::default(),
2330 mcp: crate::config::ResolvedMcpConfig::default(),
2331 scheduler_threads: 1,
2332 query_timeout: Some(Duration::from_secs(10)),
2333 default_namespace: "default".to_owned(),
2334 auto_create: crate::config::AutoCreate::Open,
2335 max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
2336 drain_timeout: Duration::from_secs(30),
2337 metrics: MetricsConfig { enabled: true },
2338 owned_shards: Vec::new(),
2339 cors_allowed_origins: Vec::new(),
2340 }
2341 }
2342
2343 #[tokio::test]
2344 async fn builds_state_with_in_memory_store() -> Result<(), Box<dyn std::error::Error>> {
2345 let state =
2346 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2347
2348 std::hint::black_box(state.namespace_guard());
2349 std::hint::black_box(state.worker_registry());
2350
2351 Ok(())
2352 }
2353
2354 #[tokio::test]
2362 async fn unserved_queues_surfaces_a_parked_dispatch_and_clears_it()
2363 -> Result<(), Box<dyn std::error::Error>> {
2364 use aion::{ActivityDispatch, ActivityDispatcher as _};
2365 use aion_core::{ActivityId, RunId, WorkflowId};
2366 use std::sync::Arc;
2367
2368 let state =
2369 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2370 assert!(
2371 state.unserved_queues()?.is_empty(),
2372 "a calm boot has no unserved queues"
2373 );
2374 assert!(state.queue_declarations().is_installed());
2378 assert_eq!(
2379 state
2380 .queue_declarations()
2381 .declaration_for("nobody-serves-this"),
2382 crate::worker::QueueDeclaration::Unknown
2383 );
2384
2385 let dispatcher = Arc::new(
2386 crate::worker::WorkerActivityDispatcher::new(
2387 state.worker_registry().clone(),
2388 "default",
2389 crate::worker::HeartbeatTracker::new(Duration::from_secs(5)),
2390 )
2391 .with_queue_state(state.queue_service_state().clone())
2392 .with_queue_declarations(state.queue_declarations().clone()),
2393 );
2394 let workflow_id = WorkflowId::new_v4();
2395 let request = ActivityDispatch {
2396 namespace: "default".to_owned(),
2397 task_queue: "nobody-serves-this".to_owned(),
2398 node: None,
2399 workflow_id: workflow_id.clone(),
2400 run_id: RunId::new_v4(),
2401 activity_id: ActivityId::from_sequence_position(0),
2402 name: "greet".to_owned(),
2403 input: "{}".to_owned(),
2404 config: "{}".to_owned(),
2405 attempt: 1,
2406 advisory: false,
2407 labels: std::collections::BTreeMap::new(),
2408 };
2409 let parked = std::thread::spawn(move || dispatcher.dispatch(request));
2410
2411 let mut unserved = Vec::new();
2412 for _ in 0..30 {
2413 unserved = state.unserved_queues()?;
2414 if !unserved.is_empty() {
2415 break;
2416 }
2417 tokio::time::sleep(Duration::from_millis(100)).await;
2418 }
2419 assert_eq!(unserved.len(), 1, "the parked dispatch is not surfaced");
2420 assert_eq!(
2421 unserved[0].reason,
2422 crate::worker::QueueServiceReason::NoLivePollers,
2423 "an empty catalog must not be read as a structural refusal"
2424 );
2425 assert_eq!(unserved[0].key.task_queue, "nobody-serves-this");
2426 assert_eq!(unserved[0].waiting.len(), 1);
2427 assert_eq!(unserved[0].waiting[0].workflow_id, workflow_id);
2428
2429 let (worker_tx, worker_rx) = tokio::sync::mpsc::channel(1);
2431 drop(worker_rx);
2432 let registration = state.worker_registry().register_namespaces(
2433 [String::from("default")],
2434 "nobody-serves-this",
2435 None,
2436 [String::from("greet")].iter(),
2437 worker_tx,
2438 )?;
2439 let outcome = parked.join().map_err(|_| "parked dispatch panicked")?;
2440 assert!(outcome.is_err(), "the released dispatch must resolve");
2441 assert!(
2442 state.unserved_queues()?.is_empty(),
2443 "a resolved dispatch must leave the unserved state"
2444 );
2445 registration.deregister()?;
2446 Ok(())
2447 }
2448
2449 #[tokio::test]
2450 async fn namespace_store_is_reachable_and_functional_after_default_boot()
2451 -> Result<(), Box<dyn std::error::Error>> {
2452 use aion_store::{MintOutcome, NamespaceOrigin};
2453
2454 let state =
2458 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2459
2460 let store = state.namespace_store();
2461
2462 let outcome = store
2464 .register_namespace("orders", NamespaceOrigin::WorkerMint)
2465 .await?;
2466 assert_eq!(
2467 outcome,
2468 MintOutcome::Created,
2469 "the first reference to a namespace mints it"
2470 );
2471
2472 let again = store
2474 .register_namespace("orders", NamespaceOrigin::WorkerMint)
2475 .await?;
2476 assert_eq!(
2477 again,
2478 MintOutcome::AlreadyExisted,
2479 "a second reference touches the existing record rather than re-creating it"
2480 );
2481
2482 let fetched = store.get_namespace("orders").await?;
2484 let record = fetched.ok_or("registered namespace must be retrievable via get_namespace")?;
2485 assert_eq!(record.name, "orders");
2486 assert_eq!(record.origin, NamespaceOrigin::WorkerMint);
2487
2488 let listed = store.list_namespaces().await?;
2490 assert!(
2491 listed.iter().any(|record| record.name == "orders"),
2492 "list_namespaces returns the minted namespace"
2493 );
2494
2495 Ok(())
2496 }
2497
2498 #[cfg(feature = "haematite-backend")]
2499 #[tokio::test(flavor = "multi_thread")]
2500 async fn connect_store_haematite_round_trips_through_event_store()
2501 -> Result<(), Box<dyn std::error::Error>> {
2502 use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
2503 use aion_store::WriteToken;
2504 use chrono::Utc;
2505
2506 use crate::config::{StoreBackend, StoreConfig};
2507
2508 let data_dir = crate::test_support::private_tempdir()?;
2509 let connected = super::connect_store(StoreConfig {
2513 backend: StoreBackend::Haematite,
2514 url: None,
2515 owned_shards: Vec::new(),
2516 data_dir: Some(data_dir.path().to_string_lossy().into_owned()),
2517 shard_count: 1,
2518 cluster: None,
2519 })
2520 .await?;
2521 let event_store = connected.event_store;
2522 assert!(
2523 connected.outbox_store.is_some(),
2524 "the haematite backend shares its leaf store as the dispatcher's outbox store"
2525 );
2526 assert!(
2527 connected.bootstrap_coordinator,
2528 "a single-node haematite boot owns all shards and bootstraps the coordinator"
2529 );
2530 assert!(
2531 connected.cluster_responder.is_none(),
2532 "a single-node (no [cluster]) haematite boot has no distributed responder"
2533 );
2534
2535 let workflow_id = WorkflowId::new_v4();
2536 let event = aion_core::Event::WorkflowStarted {
2537 envelope: EventEnvelope {
2538 seq: 1,
2539 recorded_at: Utc::now(),
2540 workflow_id: workflow_id.clone(),
2541 },
2542 workflow_type: String::from("checkout"),
2543 input: Payload::new(ContentType::Json, b"{}".to_vec()),
2544 run_id: RunId::new_v4(),
2545 parent_run_id: None,
2546 package_version: PackageVersion::new("a".repeat(64)),
2547 };
2548 event_store
2549 .append(
2550 WriteToken::recorder(),
2551 &workflow_id,
2552 std::slice::from_ref(&event),
2553 0,
2554 )
2555 .await?;
2556 let history = event_store.read_history(&workflow_id).await?;
2557 assert_eq!(
2558 history.len(),
2559 1,
2560 "an event appended through the server's dyn EventStore reads back"
2561 );
2562 Ok(())
2563 }
2564
2565 #[cfg(all(feature = "haematite-backend", unix))]
2566 #[test]
2567 fn haematite_root_swap_before_first_backend_touch_cannot_redirect_writes()
2568 -> Result<(), Box<dyn std::error::Error>> {
2569 use std::os::unix::fs::symlink;
2570
2571 let sandbox = crate::test_support::private_tempdir()?;
2572 let configured_root = sandbox.path().join("data");
2573 let held_root = sandbox.path().join("held-data");
2574 let outside = sandbox.path().join("outside");
2575 std::fs::create_dir(&outside)?;
2576 let configured = configured_root
2577 .to_str()
2578 .ok_or("temporary data path was not UTF-8")?;
2579
2580 let (store, responder) =
2581 super::build_haematite_store_with_hook(configured, 4, None, || {
2582 std::fs::rename(&configured_root, &held_root)?;
2587 symlink(&outside, &configured_root)?;
2588 Ok(())
2589 })?;
2590 assert!(responder.is_none());
2591
2592 let outside_entries = std::fs::read_dir(&outside)?.collect::<Result<Vec<_>, _>>()?;
2593 assert!(
2594 outside_entries.is_empty(),
2595 "Haematite followed the replaced ambient root and wrote outside"
2596 );
2597 assert!(held_root.join("config.json").is_file());
2598 for shard in 0..4 {
2599 let shard_path = held_root.join(format!("shard-{shard}"));
2600 assert!(shard_path.is_dir(), "shard {shard} was not materialized");
2601 assert!(
2602 std::fs::read_dir(&shard_path)?
2603 .next()
2604 .transpose()?
2605 .is_some(),
2606 "shard {shard} did not run Haematite's materialization path"
2607 );
2608 }
2609
2610 drop(store);
2611 Ok(())
2612 }
2613
2614 #[cfg(all(
2615 feature = "haematite-backend",
2616 any(target_os = "linux", target_os = "android")
2617 ))]
2618 #[tokio::test]
2619 async fn proc_fd_backend_path_survives_a_post_startup_root_swap()
2620 -> Result<(), Box<dyn std::error::Error>> {
2621 use std::os::unix::fs::symlink;
2622
2623 use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
2624 use aion_store::{WritableEventStore as _, WriteToken};
2625 use chrono::Utc;
2626
2627 let sandbox = crate::test_support::private_tempdir()?;
2628 let configured_root = sandbox.path().join("data");
2629 let held_root = sandbox.path().join("held-data");
2630 let capture = sandbox.path().join("capture");
2631 std::fs::create_dir(&capture)?;
2632 let configured = configured_root
2633 .to_str()
2634 .ok_or("temporary data path was not UTF-8")?;
2635
2636 let (store, responder) = super::build_haematite_store(configured, 4, None)?;
2637 assert!(responder.is_none());
2638 std::fs::rename(&configured_root, &held_root)?;
2639 symlink(&capture, &configured_root)?;
2640
2641 let workflow_id = WorkflowId::new_v4();
2642 let event = aion_core::Event::WorkflowStarted {
2643 envelope: EventEnvelope {
2644 seq: 1,
2645 recorded_at: Utc::now(),
2646 workflow_id: workflow_id.clone(),
2647 },
2648 workflow_type: String::from("post-startup-root-swap"),
2649 input: Payload::new(ContentType::Json, b"{}".to_vec()),
2650 run_id: RunId::new_v4(),
2651 parent_run_id: None,
2652 package_version: PackageVersion::new("a".repeat(64)),
2653 };
2654 store
2655 .append(
2656 WriteToken::recorder(),
2657 &workflow_id,
2658 std::slice::from_ref(&event),
2659 0,
2660 )
2661 .await?;
2662
2663 let captured = std::fs::read_dir(&capture)?.collect::<Result<Vec<_>, _>>()?;
2664 assert!(
2665 captured.is_empty(),
2666 "post-startup append followed the replacement symlink into capture"
2667 );
2668 assert!(held_root.join("config.json").is_file());
2669 drop(store);
2670 Ok(())
2671 }
2672
2673 #[cfg(all(
2674 feature = "haematite-backend",
2675 unix,
2676 not(any(target_os = "linux", target_os = "android"))
2677 ))]
2678 #[test]
2679 fn path_ambient_haematite_refuses_group_or_world_writable_ancestors()
2680 -> Result<(), Box<dyn std::error::Error>> {
2681 use std::os::unix::fs::PermissionsExt as _;
2682
2683 let sandbox = crate::test_support::private_tempdir()?;
2684 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2685
2686 for mode in [0o770, 0o1777] {
2687 let shared = sandbox.path().join(format!("shared-{mode:o}"));
2688 let data_root = shared.join("data");
2689 std::fs::create_dir(&shared)?;
2690 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(mode))?;
2691 std::fs::create_dir(&data_root)?;
2692 std::fs::set_permissions(&data_root, std::fs::Permissions::from_mode(0o700))?;
2693 let configured = data_root
2694 .to_str()
2695 .ok_or("temporary data path was not UTF-8")?;
2696
2697 let Err(error) = super::build_haematite_store(configured, 4, None) else {
2698 return Err(format!("mode {mode:04o} ancestor was accepted").into());
2699 };
2700 let message = error.to_string();
2701 let crate::ServerError::UnsafeDataRootAncestor {
2702 data_root: resolved_root,
2703 component,
2704 reason,
2705 } = error
2706 else {
2707 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
2708 };
2709 assert_eq!(resolved_root, std::fs::canonicalize(&data_root)?);
2710 assert_eq!(component, std::fs::canonicalize(&shared)?);
2711 assert!(
2712 reason.contains(&format!("mode {mode:04o}")),
2713 "unexpected reason: {reason}"
2714 );
2715 if mode & 0o1000 != 0 {
2716 assert!(reason.contains("sticky bit is not accepted"));
2717 }
2718 assert!(message.contains("private Aion home"));
2719 assert!(
2720 !data_root.join("config.json").exists(),
2721 "Haematite touched its ambient path before the refusal"
2722 );
2723 }
2724 Ok(())
2725 }
2726
2727 #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2728 #[test]
2729 fn path_ambient_haematite_refuses_mutating_allow_acl_ancestor()
2730 -> Result<(), Box<dyn std::error::Error>> {
2731 use std::os::unix::fs::PermissionsExt as _;
2732
2733 let sandbox = crate::test_support::private_tempdir()?;
2734 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2735 let shared = sandbox.path().join("acl-shared");
2736 let data_root = shared.join("data");
2737 std::fs::create_dir(&shared)?;
2738 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o700))?;
2739 let acl = "everyone allow list,search,add_file,add_subdirectory,delete_child";
2740 let status = std::process::Command::new("chmod")
2741 .arg("+a")
2742 .arg(acl)
2743 .arg(&shared)
2744 .status()?;
2745 assert!(status.success(), "failed to install Darwin regression ACL");
2746 let configured = data_root
2747 .to_str()
2748 .ok_or("temporary data path was not UTF-8")?;
2749
2750 let result = super::build_haematite_store(configured, 4, None);
2751 let cleanup = std::process::Command::new("chmod")
2752 .arg("-RN")
2753 .arg(&shared)
2754 .status()?;
2755 assert!(cleanup.success(), "failed to clean Darwin regression ACL");
2756
2757 let Err(error) = result else {
2758 return Err("mutating non-euid allow ACL ancestor was accepted".into());
2759 };
2760 let message = error.to_string();
2761 let crate::ServerError::UnsafeDataRootAncestor {
2762 component, reason, ..
2763 } = error
2764 else {
2765 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
2766 };
2767 assert_eq!(component, std::fs::canonicalize(&shared)?);
2768 assert!(
2769 reason.contains("allow"),
2770 "reason did not name the ACE: {reason}"
2771 );
2772 assert!(
2773 reason.contains("everyone"),
2774 "reason did not name the ACE principal: {reason}"
2775 );
2776 assert!(
2777 !data_root.join("config.json").exists(),
2778 "Haematite touched its ambient path before the ACL refusal"
2779 );
2780 Ok(())
2781 }
2782
2783 #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2784 #[test]
2785 fn path_ambient_haematite_accepts_the_euid_uuid_allow_ace()
2786 -> Result<(), Box<dyn std::error::Error>> {
2787 use std::os::unix::fs::PermissionsExt as _;
2788
2789 use exacl::{AclEntry, AclOption, Perm};
2790
2791 let sandbox = crate::test_support::private_tempdir()?;
2792 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2793 let private_parent = sandbox.path().join("euid-uuid-allow");
2794 let data_root = private_parent.join("data");
2795 std::fs::create_dir(&private_parent)?;
2796 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
2797
2798 let server_uid = rustix::process::geteuid().as_raw();
2799 let ace_qualifier = crate::filesystem::darwin_user_uuid_for_test(server_uid)?;
2800 let entry = AclEntry::allow_user(
2801 &ace_qualifier.to_string(),
2802 Perm::EXECUTE | Perm::WRITE | Perm::APPEND | Perm::DELETE_CHILD,
2803 None,
2804 );
2805 exacl::setfacl(
2806 &[private_parent.as_path()],
2807 &[entry],
2808 AclOption::SYMLINK_ACL,
2809 )?;
2810 let configured = data_root
2811 .to_str()
2812 .ok_or("temporary data path was not UTF-8")?;
2813
2814 let result = super::build_haematite_store(configured, 4, None);
2815 let cleanup = std::process::Command::new("chmod")
2816 .arg("-RN")
2817 .arg(&private_parent)
2818 .status()?;
2819 assert!(cleanup.success(), "failed to clean euid UUID allow ACL");
2820
2821 let (store, responder) = result?;
2822 assert!(responder.is_none());
2823 assert!(data_root.join("config.json").is_file());
2824 drop(store);
2825 Ok(())
2826 }
2827
2828 #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2829 #[test]
2830 fn path_ambient_haematite_refuses_a_non_euid_user_uuid_allow_ace()
2831 -> Result<(), Box<dyn std::error::Error>> {
2832 use std::os::unix::fs::PermissionsExt as _;
2833
2834 use exacl::{AclEntry, AclOption, Perm};
2835
2836 let sandbox = crate::test_support::private_tempdir()?;
2837 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2838 let shared = sandbox.path().join("non-euid-uuid-allow");
2839 let data_root = shared.join("data");
2840 std::fs::create_dir(&shared)?;
2841 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o700))?;
2842
2843 let server_uid = rustix::process::geteuid().as_raw();
2844 let foreign_uid = u32::from(server_uid == 0);
2845 let foreign_qualifier = crate::filesystem::darwin_user_uuid_for_test(foreign_uid)?;
2846 let entry = AclEntry::allow_user(
2847 &foreign_qualifier.to_string(),
2848 Perm::EXECUTE | Perm::WRITE | Perm::APPEND | Perm::DELETE_CHILD,
2849 None,
2850 );
2851 exacl::setfacl(&[shared.as_path()], &[entry], AclOption::SYMLINK_ACL)?;
2852 let configured = data_root
2853 .to_str()
2854 .ok_or("temporary data path was not UTF-8")?;
2855
2856 let result = super::build_haematite_store(configured, 4, None);
2857 let cleanup = std::process::Command::new("chmod")
2858 .arg("-RN")
2859 .arg(&shared)
2860 .status()?;
2861 assert!(cleanup.success(), "failed to clean non-euid UUID allow ACL");
2862
2863 let Err(error) = result else {
2864 return Err("mutating non-euid user UUID allow ACE was accepted".into());
2865 };
2866 let message = error.to_string();
2867 let crate::ServerError::UnsafeDataRootAncestor {
2868 component, reason, ..
2869 } = error
2870 else {
2871 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
2872 };
2873 assert_eq!(component, std::fs::canonicalize(&shared)?);
2874 assert!(
2875 reason.contains("allow") && reason.contains(&format!("server euid {server_uid}")),
2876 "reason did not name the rejected ACE: {reason}"
2877 );
2878 assert!(
2879 !data_root.join("config.json").exists(),
2880 "Haematite touched its ambient path before the UUID ACL refusal"
2881 );
2882 Ok(())
2883 }
2884
2885 #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2886 #[test]
2887 fn path_ambient_haematite_accepts_a_deny_only_acl_ancestor()
2888 -> Result<(), Box<dyn std::error::Error>> {
2889 use std::os::unix::fs::PermissionsExt as _;
2890
2891 let sandbox = crate::test_support::private_tempdir()?;
2892 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2893 let private_parent = sandbox.path().join("deny-only");
2894 let data_root = private_parent.join("data");
2895 std::fs::create_dir(&private_parent)?;
2896 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
2897 let status = std::process::Command::new("chmod")
2898 .arg("+a")
2899 .arg("everyone deny delete")
2900 .arg(&private_parent)
2901 .status()?;
2902 assert!(status.success(), "failed to install Darwin deny-only ACL");
2903 let configured = data_root
2904 .to_str()
2905 .ok_or("temporary data path was not UTF-8")?;
2906
2907 let result = super::build_haematite_store(configured, 4, None);
2908 let cleanup = std::process::Command::new("chmod")
2909 .arg("-RN")
2910 .arg(&private_parent)
2911 .status()?;
2912 assert!(cleanup.success(), "failed to clean Darwin deny-only ACL");
2913
2914 let (store, responder) = result?;
2915 assert!(responder.is_none());
2916 assert!(data_root.join("config.json").is_file());
2917 drop(store);
2918 Ok(())
2919 }
2920
2921 #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2922 #[test]
2923 fn path_ambient_haematite_accepts_the_stock_home_acl_chain()
2924 -> Result<(), Box<dyn std::error::Error>> {
2925 use std::os::unix::fs::PermissionsExt as _;
2926 use users::os::unix::UserExt as _;
2927
2928 let effective_uid = rustix::process::geteuid().as_raw();
2929 let effective_user = users::get_user_by_uid(effective_uid)
2930 .ok_or_else(|| format!("server euid {effective_uid} has no account record"))?;
2931 let sandbox = tempfile::Builder::new()
2932 .prefix(".aion-acl-home-proof-")
2933 .tempdir_in(effective_user.home_dir())?;
2934 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2935 let data_root = sandbox.path().join("data");
2936 let configured = data_root
2937 .to_str()
2938 .ok_or("temporary data path was not UTF-8")?;
2939
2940 let (store, responder) = super::build_haematite_store(configured, 4, None)?;
2941 assert!(responder.is_none());
2942 assert!(data_root.join("config.json").is_file());
2943 drop(store);
2944 Ok(())
2945 }
2946
2947 #[cfg(all(
2948 feature = "haematite-backend",
2949 unix,
2950 not(any(target_os = "linux", target_os = "android"))
2951 ))]
2952 #[test]
2953 fn path_ambient_haematite_accepts_an_owner_controlled_chain()
2954 -> Result<(), Box<dyn std::error::Error>> {
2955 use std::os::unix::fs::PermissionsExt as _;
2956
2957 let sandbox = crate::test_support::private_tempdir()?;
2958 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2959 let private_parent = sandbox.path().join("private");
2960 let data_root = private_parent.join("data");
2961 std::fs::create_dir(&private_parent)?;
2962 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
2963 let configured = data_root
2964 .to_str()
2965 .ok_or("temporary data path was not UTF-8")?;
2966
2967 let (store, responder) = super::build_haematite_store(configured, 4, None)?;
2968 assert!(responder.is_none());
2969 assert!(data_root.join("config.json").is_file());
2970 for shard in 0..4 {
2971 assert!(data_root.join(format!("shard-{shard}")).is_dir());
2972 }
2973 drop(store);
2974 Ok(())
2975 }
2976
2977 #[tokio::test]
2978 async fn connect_store_memory_backend_exposes_no_outbox_store()
2979 -> Result<(), Box<dyn std::error::Error>> {
2980 use crate::config::{StoreBackend, StoreConfig};
2981
2982 let connected = super::connect_store(StoreConfig {
2985 backend: StoreBackend::Memory,
2986 url: None,
2987 owned_shards: Vec::new(),
2988 data_dir: None,
2989 shard_count: 1,
2990 cluster: None,
2991 })
2992 .await?;
2993 assert!(
2994 connected.outbox_store.is_none(),
2995 "the in-memory backend exposes no outbox store"
2996 );
2997 Ok(())
2998 }
2999
3000 #[cfg(feature = "libsql-backend")]
3004 #[tokio::test]
3005 async fn connect_store_shares_outbox_store_only_for_libsql()
3006 -> Result<(), Box<dyn std::error::Error>> {
3007 use crate::config::{StoreBackend, StoreConfig};
3008
3009 let path = std::env::temp_dir().join(format!(
3014 "aion-connect-store-{}-{}.db",
3015 std::process::id(),
3016 std::time::SystemTime::now()
3017 .duration_since(std::time::UNIX_EPOCH)
3018 .map(|elapsed| elapsed.as_nanos())
3019 .unwrap_or_default()
3020 ));
3021 let connected = super::connect_store(StoreConfig {
3022 backend: StoreBackend::LibSql,
3023 url: Some(path.to_string_lossy().into_owned()),
3024 owned_shards: Vec::new(),
3025 data_dir: None,
3026 shard_count: 1,
3027 cluster: None,
3028 })
3029 .await?;
3030 assert!(
3031 connected.outbox_store.is_some(),
3032 "the libSQL backend shares its leaf store as the dispatcher's outbox store"
3033 );
3034 Ok(())
3035 }
3036
3037 #[tokio::test]
3038 async fn state_build_fails_without_event_broadcast_capacity()
3039 -> Result<(), Box<dyn std::error::Error>> {
3040 let mut runtime = runtime_config();
3041 runtime.websocket.event_broadcast_capacity = None;
3042
3043 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
3044 .await
3045 .err()
3046 .ok_or("state build must fail when event streaming is unsized")?;
3047
3048 assert!(error.is_config(), "expected a config error, got {error}");
3049 assert!(
3050 error
3051 .to_string()
3052 .contains("websocket.event_broadcast_capacity"),
3053 "error must name the missing key: {error}"
3054 );
3055 Ok(())
3056 }
3057
3058 #[tokio::test]
3059 async fn state_build_fails_without_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
3060 let mut runtime = runtime_config();
3061 runtime.query_timeout = None;
3062
3063 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
3064 .await
3065 .err()
3066 .ok_or("state build must fail when the query reply deadline is unset")?;
3067
3068 assert!(error.is_config(), "expected a config error, got {error}");
3069 assert!(
3070 error.to_string().contains("runtime.query_timeout_ms"),
3071 "error must name the missing key: {error}"
3072 );
3073 assert!(
3074 error.to_string().contains("AION_RUNTIME_QUERY_TIMEOUT_MS"),
3075 "error must name the environment override: {error}"
3076 );
3077 Ok(())
3078 }
3079
3080 #[tokio::test]
3081 async fn state_build_fails_with_zero_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
3082 let mut runtime = runtime_config();
3083 runtime.query_timeout = Some(Duration::ZERO);
3084
3085 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
3086 .await
3087 .err()
3088 .ok_or("state build must fail when the query reply deadline is zero")?;
3089
3090 assert!(error.is_config(), "expected a config error, got {error}");
3091 assert!(
3092 error.to_string().contains("runtime.query_timeout_ms"),
3093 "error must name the zero-valued key: {error}"
3094 );
3095 Ok(())
3096 }
3097
3098 #[tokio::test(flavor = "multi_thread")]
3121 async fn a_completed_check_through_the_built_dispatcher_lands_in_the_returned_slot()
3122 -> Result<(), Box<dyn std::error::Error>> {
3123 use std::collections::{BTreeMap, VecDeque};
3124 use std::sync::{Arc, Mutex};
3125
3126 use aion::ActivityDispatch;
3127 use aion_core::{ActivityId, RunId, WorkflowId};
3128 use aion_package::ActionBodyContract;
3129
3130 use crate::update_check::document::{FETCH_ACTION, FETCH_COMMAND, UPDATE_CHECK_QUEUE};
3131 use crate::worker::{DeclaredBodies, DeclaredBodyLookup, DispatchingRun};
3132
3133 struct SequencedBodies {
3136 replies: Mutex<VecDeque<DeclaredBodyLookup>>,
3137 }
3138
3139 impl DeclaredBodies for SequencedBodies {
3140 fn body_for(
3141 &self,
3142 _task_queue: &str,
3143 _action: &str,
3144 _run: DispatchingRun<'_>,
3145 ) -> DeclaredBodyLookup {
3146 let mut replies = match self.replies.lock() {
3147 Ok(replies) => replies,
3148 Err(poisoned) => poisoned.into_inner(),
3149 };
3150 replies.pop_front().unwrap_or(DeclaredBodyLookup::None)
3151 }
3152 }
3153
3154 let runtime = runtime_config();
3155 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
3156 ServerState::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
3157 );
3158 let namespace_store: Arc<dyn aion_store::NamespaceStore> =
3159 Arc::new(InMemoryStore::default());
3160 let worker_deployment_store: Arc<dyn aion_store::WorkerDeploymentStore> =
3161 Arc::new(InMemoryStore::default());
3162 let seams = super::build_worker_seams(
3163 &runtime,
3164 &cluster_publisher,
3165 &namespace_store,
3166 &worker_deployment_store,
3167 None,
3168 );
3169
3170 let fixture = concat!(
3173 env!("CARGO_MANIFEST_DIR"),
3174 "/src/update_check/fixtures/aion-cli-index.jsonl"
3175 );
3176 seams.declared_bodies.install(Arc::new(SequencedBodies {
3177 replies: Mutex::new(VecDeque::from([
3178 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
3179 command: FETCH_COMMAND.to_owned(),
3180 }),
3181 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
3182 command: format!("cat {fixture}"),
3183 }),
3184 ])),
3185 }));
3186
3187 let transcript = crate::activity_publisher::ActivityEventPublisher::new(
3188 Arc::new(aion_store::InMemoryObservabilityStore::default()),
3189 ServerState::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
3190 );
3191 let (dispatcher, _mock_registry, _attempt_owners, _workspace_root, update_status) =
3192 super::build_decorated_dispatcher(&runtime, &seams, transcript);
3193
3194 assert_eq!(
3195 update_status.last(),
3196 None,
3197 "the returned slot must start honestly empty"
3198 );
3199
3200 let dispatch = ActivityDispatch {
3201 namespace: "default".to_owned(),
3202 task_queue: UPDATE_CHECK_QUEUE.to_owned(),
3203 node: None,
3204 workflow_id: WorkflowId::new_v4(),
3205 run_id: RunId::new_v4(),
3206 activity_id: ActivityId::from_sequence_position(1),
3207 name: FETCH_ACTION.to_owned(),
3208 input: "{}".to_owned(),
3209 config: "{}".to_owned(),
3210 attempt: 1,
3211 labels: BTreeMap::new(),
3212 advisory: false,
3213 };
3214 let handle = tokio::task::spawn_blocking(move || dispatcher.dispatch(dispatch));
3215 let encoded = handle
3216 .await?
3217 .map_err(|error| format!("the check dispatch failed: {error}"))?;
3218 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
3219 assert_eq!(outcome["exit_code"], 0, "the local stand-in command ran");
3220
3221 let recorded = update_status.last().ok_or(
3222 "the completed check must land in the RETURNED slot — the one the boot path \
3223 stores and /update-status serves; an empty slot here is the disconnected-\
3224 producer mis-wire the r1 review proved unmeasured",
3225 )?;
3226 assert_eq!(recorded.latest_known, "0.13.7");
3227 Ok(())
3228 }
3229}