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
1291fn server_search_attribute_schema() -> Result<aion_core::SearchAttributeSchema, ServerError> {
1300 let mut schema = aion_core::SearchAttributeSchema::new();
1301 for (name, label) in [
1302 (crate::namespace::NAMESPACE_ATTRIBUTE, "namespace"),
1303 (crate::namespace::TASK_QUEUE_ATTRIBUTE, "task_queue"),
1304 (crate::namespace::DISPLAY_NAME_ATTRIBUTE, "display_name"),
1305 ] {
1306 schema
1307 .register(name, aion_core::SearchAttributeType::String)
1308 .map_err(|error| ServerError::Config {
1309 message: format!("failed to register {label} search attribute: {error}"),
1310 })?;
1311 }
1312 Ok(schema)
1313}
1314
1315async fn build_engine(assembly: EngineAssembly<'_>) -> Result<aion::Engine, ServerError> {
1322 let search_attribute_schema = server_search_attribute_schema()?;
1323 let runtime = assembly.runtime;
1324 let builder = EngineBuilder::new()
1325 .store_arc(assembly.instrumented_store.clone())
1326 .event_streaming(assembly.event_broadcast_capacity)
1327 .in_memory_visibility()
1328 .search_attribute_schema(search_attribute_schema)
1329 .scheduler_threads(runtime.scheduler_threads)
1330 .outbox_enabled(runtime.outbox.enabled)
1331 .activity_dispatcher(assembly.activity_dispatcher)
1332 .active_registry(assembly.active_registry)
1333 .production_recovery_seam()
1334 .signal_router_factory(|runtime: Arc<RuntimeHandle>, handoff| {
1335 Arc::new(ConcreteSignalRouter::new(runtime, handoff)) as Arc<dyn SignalRouter>
1336 })
1337 .query_timeout(assembly.query_timeout)
1338 .bootstrap_schedule_coordinator(assembly.bootstrap_coordinator)
1344 .load_workflow_sources(runtime.workflow_packages.iter().map(PathBuf::as_path));
1345 let builder = if runtime.owned_shards.is_empty() {
1351 builder
1352 } else {
1353 builder.owned_shards(runtime.owned_shards.iter().copied())
1354 };
1355 builder.build().await.map_err(ServerError::from)
1356}
1357
1358fn required_engine_seams(
1363 runtime: &RuntimeConfig,
1364) -> Result<(std::num::NonZeroUsize, std::time::Duration), ServerError> {
1365 let event_broadcast_capacity = runtime
1366 .websocket
1367 .event_broadcast_capacity
1368 .and_then(std::num::NonZeroUsize::new)
1369 .ok_or_else(|| ServerError::Config {
1370 message: crate::config::EVENT_BROADCAST_CAPACITY_REQUIRED.to_owned(),
1371 })?;
1372 let query_timeout = runtime
1373 .query_timeout
1374 .filter(|timeout| !timeout.is_zero())
1375 .ok_or_else(|| ServerError::Config {
1376 message: crate::config::QUERY_TIMEOUT_REQUIRED.to_owned(),
1377 })?;
1378 Ok((event_broadcast_capacity, query_timeout))
1379}
1380
1381fn install_outbox_delivery(
1388 pending_activities: &PendingActivities,
1389 engine: &Arc<aion::Engine>,
1390 outbox_enabled: bool,
1391) {
1392 if outbox_enabled {
1393 let callback = Arc::new(crate::worker::ServerOutboxDeliveryCallback::new(
1394 Arc::clone(engine),
1395 ));
1396 pending_activities.set_outbox_delivery(callback);
1397 }
1398}
1399
1400struct WorkerSeams {
1402 worker_registry: ConnectedWorkerRegistry,
1403 pending_activities: PendingActivities,
1404 heartbeat_tracker: HeartbeatTracker,
1405 drain_state: DrainState,
1406 queue_declarations: crate::worker::QueueDeclarationSource,
1407 queue_service_state: crate::worker::QueueServiceState,
1408 declared_bodies: crate::worker::DeclaredBodySource,
1409}
1410
1411fn build_worker_seams(
1416 runtime: &RuntimeConfig,
1417 cluster_publisher: &crate::cluster_publisher::ClusterEventPublisher,
1418 namespace_store: &Arc<dyn NamespaceStore>,
1419 worker_deployment_store: &Arc<dyn WorkerDeploymentStore>,
1420 mint_routing: Option<crate::namespace::NamespaceRouting>,
1421) -> WorkerSeams {
1422 let worker_registry = ConnectedWorkerRegistry::default()
1423 .with_cluster_publisher(cluster_publisher.clone())
1424 .with_namespace_minting(namespace_store.clone(), runtime.auto_create)
1425 .with_worker_deployment_store(worker_deployment_store.clone());
1426 let worker_registry = match mint_routing {
1432 Some(routing) => worker_registry.with_namespace_routing(routing),
1433 None => worker_registry,
1434 };
1435 WorkerSeams {
1436 worker_registry,
1437 pending_activities: PendingActivities::default()
1442 .with_heartbeat_window(runtime.worker.heartbeat_window),
1443 heartbeat_tracker: HeartbeatTracker::new(runtime.worker.heartbeat_window),
1444 drain_state: DrainState::default(),
1445 queue_declarations: crate::worker::QueueDeclarationSource::default(),
1446 queue_service_state: crate::worker::QueueServiceState::default(),
1447 declared_bodies: crate::worker::DeclaredBodySource::default(),
1448 }
1449}
1450
1451fn install_queue_declarations(
1458 queue_declarations: &crate::worker::QueueDeclarationSource,
1459 engine: &Arc<aion::Engine>,
1460) {
1461 queue_declarations.install(Arc::new(crate::worker::EngineQueueDeclarations::new(
1462 Arc::clone(engine),
1463 )));
1464}
1465
1466fn install_declared_bodies(
1474 declared_bodies: &crate::worker::DeclaredBodySource,
1475 engine: &Arc<aion::Engine>,
1476) {
1477 declared_bodies.install(Arc::new(crate::worker::EngineDeclaredBodies::new(
1478 Arc::clone(engine),
1479 )));
1480}
1481
1482fn install_engine_backed_seams(seams: &WorkerSeams, engine: &Arc<aion::Engine>, outbox: bool) {
1488 install_outbox_delivery(&seams.pending_activities, engine, outbox);
1489 install_queue_declarations(&seams.queue_declarations, engine);
1490 install_declared_bodies(&seams.declared_bodies, engine);
1491}
1492
1493fn build_bridge_dispatcher(
1506 runtime: &RuntimeConfig,
1507 seams: &WorkerSeams,
1508) -> (WorkerActivityDispatcher, crate::worker::AttemptOwnerIndex) {
1509 let attempt_owners = crate::worker::AttemptOwnerIndex::new();
1510 let dispatcher = WorkerActivityDispatcher::new(
1511 seams.worker_registry.clone(),
1512 runtime.default_namespace.clone(),
1513 seams.heartbeat_tracker.clone(),
1514 )
1515 .with_pending(seams.pending_activities.clone())
1516 .with_drain_state(seams.drain_state.clone())
1517 .with_tokio_handle(tokio::runtime::Handle::current())
1518 .with_attempt_owners(attempt_owners.clone())
1519 .with_queue_service(runtime.worker.queue_service.clone())
1520 .with_queue_declarations(seams.queue_declarations.clone())
1521 .with_queue_state(seams.queue_service_state.clone());
1522 (dispatcher, attempt_owners)
1523}
1524
1525fn build_instrumented_store(
1538 runtime: &RuntimeConfig,
1539 event_store: Arc<dyn EventStore>,
1540) -> Result<
1541 (
1542 Metrics,
1543 Arc<tokio::sync::Notify>,
1544 Arc<InstrumentedEventStore>,
1545 ),
1546 ServerError,
1547> {
1548 let metrics = Metrics::new().map_err(|error| metrics_config_error(&error))?;
1549 let outbox_wake = Arc::new(tokio::sync::Notify::new());
1550 let instrumented_store = Arc::new(
1551 InstrumentedEventStore::new(
1552 event_store,
1553 metrics.clone(),
1554 runtime.default_namespace.clone(),
1555 )
1556 .with_outbox_wake(Arc::clone(&outbox_wake)),
1557 );
1558 Ok((metrics, outbox_wake, instrumented_store))
1559}
1560
1561fn build_decorated_dispatcher(
1566 runtime: &RuntimeConfig,
1567 seams: &WorkerSeams,
1568 transcript: crate::activity_publisher::ActivityEventPublisher,
1569) -> (
1570 Arc<dyn ActivityDispatcher>,
1571 Option<ActivityMockRegistry>,
1572 crate::worker::AttemptOwnerIndex,
1573 crate::worker::WorkspaceRoot,
1574 crate::update_check::UpdateStatusState,
1575) {
1576 let workspace_root = crate::worker::WorkspaceRoot::resolve();
1580 let update_status = crate::update_check::UpdateStatusState::default();
1583 let (dispatcher, attempt_owners) = build_bridge_dispatcher(runtime, seams);
1584 let (activity_dispatcher, activity_mock_registry) = decorate_activity_dispatcher(
1585 dispatcher,
1586 seams.declared_bodies.clone(),
1587 workspace_root.clone(),
1588 transcript,
1589 update_status.clone(),
1590 runtime.dev.enabled,
1591 );
1592 (
1593 activity_dispatcher,
1594 activity_mock_registry,
1595 attempt_owners,
1596 workspace_root,
1597 update_status,
1598 )
1599}
1600
1601fn decorate_activity_dispatcher(
1602 dispatcher: WorkerActivityDispatcher,
1603 declared_bodies: crate::worker::DeclaredBodySource,
1604 workspace_root: crate::worker::WorkspaceRoot,
1605 transcript: crate::activity_publisher::ActivityEventPublisher,
1606 update_status: crate::update_check::UpdateStatusState,
1607 dev_enabled: bool,
1608) -> (Arc<dyn ActivityDispatcher>, Option<ActivityMockRegistry>) {
1609 let declared = crate::worker::DeclaredCommandDispatcher::new(
1617 Arc::new(dispatcher),
1618 declared_bodies.clone(),
1619 tokio::runtime::Handle::current(),
1620 workspace_root,
1621 transcript,
1622 );
1623 let observed = crate::update_check::UpdateCheckObserver::new(
1624 Arc::new(declared),
1625 declared_bodies,
1626 update_status,
1627 );
1628 if dev_enabled {
1629 let registry = ActivityMockRegistry::new();
1630 let decorated = DevMockingDispatcher::new(Arc::new(observed), registry.clone());
1631 (Arc::new(decorated), Some(registry))
1632 } else {
1633 (Arc::new(observed), None)
1634 }
1635}
1636
1637fn required_cluster_broadcast_capacity(
1642 runtime: &RuntimeConfig,
1643) -> Result<std::num::NonZeroUsize, ServerError> {
1644 runtime
1645 .websocket
1646 .cluster_broadcast_capacity
1647 .and_then(std::num::NonZeroUsize::new)
1648 .ok_or_else(|| ServerError::Config {
1649 message: crate::config::CLUSTER_BROADCAST_CAPACITY_REQUIRED.to_owned(),
1650 })
1651}
1652
1653fn build_real_time_publishers(
1666 runtime: &RuntimeConfig,
1667 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1668) -> Result<
1669 (
1670 crate::cluster_publisher::ClusterEventPublisher,
1671 crate::activity_publisher::ActivityEventPublisher,
1672 ),
1673 ServerError,
1674> {
1675 let capacity = required_cluster_broadcast_capacity(runtime)?;
1676 Ok((
1677 crate::cluster_publisher::ClusterEventPublisher::new(capacity),
1678 build_transcript_publisher(observability_store, capacity, transcript_bounds(runtime)),
1679 ))
1680}
1681
1682fn transcript_bounds(runtime: &RuntimeConfig) -> crate::activity_bounds::TranscriptBounds {
1684 crate::activity_bounds::TranscriptBounds {
1685 max_event_bytes: runtime.observability.max_event_bytes,
1686 max_stream_events: runtime.observability.max_stream_events,
1687 }
1688}
1689
1690fn build_transcript_publisher(
1701 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1702 capacity: std::num::NonZeroUsize,
1703 bounds: crate::activity_bounds::TranscriptBounds,
1704) -> crate::activity_publisher::ActivityEventPublisher {
1705 let store = observability_store
1706 .unwrap_or_else(|| Arc::new(aion_store::InMemoryObservabilityStore::default()));
1707 crate::activity_publisher::ActivityEventPublisher::new(store, capacity).with_bounds(bounds)
1708}
1709
1710#[cfg(feature = "haematite-backend")]
1712struct RoutingState {
1713 shard_directory: Option<Arc<crate::routing::StaticShardDirectory>>,
1714 request_forwarder: Option<Arc<dyn crate::routing::RequestForwarder>>,
1715 mint_routing: Option<crate::namespace::NamespaceRouting>,
1720}
1721
1722#[cfg(feature = "haematite-backend")]
1726fn build_routing_state(
1727 cluster_store: Option<&Arc<aion_store_haematite::HaematiteStore>>,
1728 directory_peers: Vec<crate::routing::DirectoryPeer>,
1729 self_node_id: Option<String>,
1730) -> RoutingState {
1731 let Some(store) = cluster_store else {
1732 return RoutingState {
1733 shard_directory: None,
1734 request_forwarder: None,
1735 mint_routing: None,
1736 };
1737 };
1738 let shard_directory = Arc::new(crate::routing::StaticShardDirectory::new(
1739 Arc::clone(store),
1740 directory_peers,
1741 self_node_id,
1742 ));
1743 let request_forwarder: Arc<dyn crate::routing::RequestForwarder> =
1744 Arc::new(crate::routing::GrpcRequestForwarder::new());
1745 let mint_routing = build_namespace_routing(
1746 Some(store),
1747 Some(&shard_directory),
1748 Some(&request_forwarder),
1749 );
1750 RoutingState {
1751 shard_directory: Some(shard_directory),
1752 request_forwarder: Some(request_forwarder),
1753 mint_routing,
1754 }
1755}
1756
1757#[cfg(feature = "haematite-backend")]
1767fn build_namespace_routing(
1768 cluster_store: Option<&Arc<aion_store_haematite::HaematiteStore>>,
1769 shard_directory: Option<&Arc<crate::routing::StaticShardDirectory>>,
1770 request_forwarder: Option<&Arc<dyn crate::routing::RequestForwarder>>,
1771) -> Option<crate::namespace::NamespaceRouting> {
1772 use crate::namespace::{
1773 GrpcMintForwarder, MintForwarder, MintShardOwners, NamespaceRouting, NamespaceShardResolver,
1774 };
1775 let store = Arc::clone(cluster_store?);
1776 let directory = Arc::clone(shard_directory?);
1777 let shards: Arc<dyn NamespaceShardResolver> = store;
1778 let owners: Arc<dyn MintShardOwners> = directory;
1779 let forwarder: Arc<dyn MintForwarder> =
1780 Arc::new(GrpcMintForwarder::new(Arc::clone(request_forwarder?)));
1781 Some(NamespaceRouting::new(shards, owners, forwarder))
1782}
1783
1784struct ConnectedStore {
1794 event_store: Arc<dyn EventStore>,
1795 outbox_store: Option<Arc<dyn OutboxStore>>,
1796 namespace_store: Arc<dyn NamespaceStore>,
1803 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
1805 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1813 bootstrap_coordinator: bool,
1814 #[cfg(feature = "haematite-backend")]
1815 cluster_responder: Option<aion_store_haematite::ClusterResponder>,
1816 #[cfg(feature = "haematite-backend")]
1820 cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
1821 #[cfg(feature = "haematite-backend")]
1824 watched_peers: Vec<crate::cluster::WatchedPeer>,
1825 #[cfg(feature = "haematite-backend")]
1829 directory_peers: Vec<crate::routing::DirectoryPeer>,
1830 #[cfg(feature = "haematite-backend")]
1834 self_node_id: Option<String>,
1835}
1836
1837impl ConnectedStore {
1838 fn local(
1845 event_store: Arc<dyn EventStore>,
1846 outbox_store: Option<Arc<dyn OutboxStore>>,
1847 namespace_store: Arc<dyn NamespaceStore>,
1848 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
1849 ) -> Self {
1850 Self {
1851 event_store,
1852 outbox_store,
1853 namespace_store,
1854 worker_deployment_store,
1855 observability_store: None,
1859 bootstrap_coordinator: true,
1860 #[cfg(feature = "haematite-backend")]
1861 cluster_responder: None,
1862 #[cfg(feature = "haematite-backend")]
1863 cluster_store: None,
1864 #[cfg(feature = "haematite-backend")]
1865 watched_peers: Vec::new(),
1866 #[cfg(feature = "haematite-backend")]
1867 directory_peers: Vec::new(),
1868 #[cfg(feature = "haematite-backend")]
1869 self_node_id: None,
1870 }
1871 }
1872
1873 #[cfg(feature = "haematite-backend")]
1876 fn cluster_self_node(&self) -> Option<String> {
1877 self.self_node_id.clone()
1878 }
1879
1880 #[cfg(not(feature = "haematite-backend"))]
1882 const fn cluster_self_node(&self) -> Option<String> {
1883 None
1884 }
1885}
1886
1887async fn connect_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
1897 match config.backend {
1898 StoreBackend::Memory => {
1899 let leaf = Arc::new(aion_store::InMemoryStore::default());
1902 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
1903 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
1904 Ok(ConnectedStore::local(
1905 leaf,
1906 None,
1907 namespace_store,
1908 worker_deployment_store,
1909 ))
1910 }
1911 StoreBackend::LibSql => {
1912 #[cfg(feature = "libsql-backend")]
1913 {
1914 connect_libsql_store(config).await
1915 }
1916 #[cfg(not(feature = "libsql-backend"))]
1917 {
1918 let _ = config;
1919 connect_libsql_store_unavailable()
1920 }
1921 }
1922 StoreBackend::Haematite => {
1923 #[cfg(feature = "haematite-backend")]
1924 {
1925 connect_haematite_store(config).await
1926 }
1927 #[cfg(not(feature = "haematite-backend"))]
1928 {
1929 let _ = config;
1930 connect_haematite_store_unavailable()
1931 }
1932 }
1933 }
1934}
1935
1936#[cfg(feature = "libsql-backend")]
1940async fn connect_libsql_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
1941 let Some(url) = config.url else {
1942 return Err(ServerError::Config {
1943 message: "store.url must not be empty when store.backend is libsql".to_owned(),
1944 });
1945 };
1946 let store = LibSqlStore::open(url.clone())
1947 .await
1948 .map_err(ServerError::from)?;
1949 store
1950 .validate_event_compatibility()
1951 .await
1952 .map_err(|error| match error {
1953 aion_store::StoreError::Serialization(_) => ServerError::Config {
1954 message: format!(
1955 "Database schema mismatch — delete {url} and restart, or run migrations."
1956 ),
1957 },
1958 other => ServerError::from(other),
1959 })?;
1960 let leaf = Arc::new(store);
1961 let event_store: Arc<dyn EventStore> = leaf.clone();
1962 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
1963 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
1964 let outbox_store: Arc<dyn OutboxStore> = leaf;
1965 Ok(ConnectedStore::local(
1966 event_store,
1967 Some(outbox_store),
1968 namespace_store,
1969 worker_deployment_store,
1970 ))
1971}
1972
1973#[cfg(not(feature = "libsql-backend"))]
1977fn connect_libsql_store_unavailable() -> Result<ConnectedStore, ServerError> {
1978 Err(ServerError::Config {
1979 message: "store.backend = libsql requires the aion-server `libsql-backend` feature"
1980 .to_owned(),
1981 })
1982}
1983
1984#[cfg(feature = "haematite-backend")]
2004async fn connect_haematite_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
2005 let Some(data_dir) = config.data_dir else {
2006 return Err(ServerError::Config {
2007 message: "store.data_dir must not be empty when store.backend is haematite".to_owned(),
2008 });
2009 };
2010 let shard_count = config.shard_count;
2011 let owned_shards = config.owned_shards.clone();
2012 let cluster = config.cluster.clone();
2013 let watched_peers: Vec<crate::cluster::WatchedPeer> = cluster
2018 .as_ref()
2019 .map(|cluster| {
2020 cluster
2021 .peers
2022 .iter()
2023 .map(|peer| crate::cluster::WatchedPeer {
2024 name: peer.name.clone(),
2025 owned_shards: peer.owned_shards.clone(),
2026 })
2027 .collect()
2028 })
2029 .unwrap_or_default();
2030 let directory_peers: Vec<crate::routing::DirectoryPeer> = cluster
2033 .as_ref()
2034 .map(|cluster| {
2035 cluster
2036 .peers
2037 .iter()
2038 .map(|peer| crate::routing::DirectoryPeer {
2039 name: peer.name.clone(),
2040 owned_shards: peer.owned_shards.clone(),
2041 grpc_addr: peer.grpc_address,
2042 })
2043 .collect()
2044 })
2045 .unwrap_or_default();
2046 let self_node_id: Option<String> = cluster.as_ref().map(|cluster| cluster.node_id.clone());
2049 let (store, responder) =
2053 tokio::task::spawn_blocking(move || build_haematite_store(&data_dir, shard_count, cluster))
2054 .await
2055 .map_err(|error| ServerError::Config {
2056 message: format!("haematite store initialization task failed: {error}"),
2057 })??;
2058
2059 let bootstrap_coordinator = if owned_shards.is_empty() {
2063 true
2064 } else {
2065 store.set_owned_shards(owned_shards.iter().copied());
2066 store.owns_workflow_shard(&aion::schedule_coordinator_workflow_id())
2067 };
2068
2069 let leaf = Arc::new(store);
2070 let event_store: Arc<dyn EventStore> = leaf.clone();
2071 let outbox_store: Arc<dyn OutboxStore> = leaf.clone();
2072 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
2076 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
2077 let observability_store: Arc<dyn aion_store::ObservabilityStore> = leaf.clone();
2082 let cluster_store = responder.as_ref().map(|_| leaf);
2086 let (watched_peers, directory_peers, self_node_id) = if cluster_store.is_some() {
2087 (watched_peers, directory_peers, self_node_id)
2088 } else {
2089 (Vec::new(), Vec::new(), None)
2090 };
2091 Ok(ConnectedStore {
2092 event_store,
2093 outbox_store: Some(outbox_store),
2094 namespace_store,
2095 worker_deployment_store,
2096 observability_store: Some(observability_store),
2097 bootstrap_coordinator,
2098 cluster_responder: responder,
2099 cluster_store,
2100 watched_peers,
2101 directory_peers,
2102 self_node_id,
2103 })
2104}
2105
2106#[cfg(feature = "haematite-backend")]
2121fn build_haematite_store(
2122 data_dir: &str,
2123 shard_count: usize,
2124 cluster: Option<crate::config::ClusterConfig>,
2125) -> Result<
2126 (
2127 aion_store_haematite::HaematiteStore,
2128 Option<aion_store_haematite::ClusterResponder>,
2129 ),
2130 ServerError,
2131> {
2132 build_haematite_store_with_hook(data_dir, shard_count, cluster, || Ok(()))
2133}
2134
2135#[cfg(feature = "haematite-backend")]
2136fn build_haematite_store_with_hook(
2137 data_dir: &str,
2138 shard_count: usize,
2139 cluster: Option<crate::config::ClusterConfig>,
2140 before_backend_touch: impl FnOnce() -> Result<(), std::io::Error>,
2141) -> Result<
2142 (
2143 aion_store_haematite::HaematiteStore,
2144 Option<aion_store_haematite::ClusterResponder>,
2145 ),
2146 ServerError,
2147> {
2148 use aion_store_haematite::{ClusterBootstrap, HaematiteStore};
2149
2150 let private_root = crate::filesystem::ConfinedDir::open_or_create(std::path::Path::new(
2158 data_dir,
2159 ))
2160 .map_err(|error| ServerError::Config {
2161 message: format!("unsafe store.data_dir `{data_dir}`: {error}"),
2162 })?;
2163
2164 for shard in 0..shard_count {
2168 private_root
2169 .create_dir_all(std::path::Path::new(&format!("shard-{shard}")))
2170 .map_err(|error| ServerError::Config {
2171 message: format!(
2172 "failed to materialize shard-{shard} under store.data_dir `{data_dir}`: {error}"
2173 ),
2174 })?;
2175 }
2176 private_root
2177 .harden_tree()
2178 .map_err(|error| private_store_mode_error(data_dir, &error))?;
2179
2180 before_backend_touch().map_err(|error| ServerError::Config {
2183 message: format!("store.data_dir pre-open hook failed: {error}"),
2184 })?;
2185
2186 #[cfg(unix)]
2187 let backend_path = private_root
2188 .backend_path()
2189 .map_err(|error| ServerError::Config {
2190 message: format!("failed to resolve held store.data_dir `{data_dir}`: {error}"),
2191 })?;
2192 #[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
2193 crate::filesystem::validate_ambient_backend_ancestors(&backend_path).map_err(|error| {
2194 let (component, reason) = error.into_parts();
2195 ServerError::UnsafeDataRootAncestor {
2196 data_root: backend_path.clone(),
2197 component,
2198 reason,
2199 }
2200 })?;
2201 #[cfg(not(unix))]
2202 let backend_path = std::path::PathBuf::from(data_dir);
2203
2204 let Some(cluster) = cluster else {
2205 let store = if backend_path.join("config.json").exists() {
2206 HaematiteStore::open(&backend_path).map_err(ServerError::from)?
2207 } else {
2208 HaematiteStore::create_with_shard_count(&backend_path, shard_count)
2209 .map_err(ServerError::from)?
2210 };
2211 store.materialize_all_shards().map_err(ServerError::from)?;
2212 private_root
2213 .harden_tree()
2214 .map_err(|error| private_store_mode_error(data_dir, &error))?;
2215 let store = store.retain_data_root_capability(private_root);
2216 return Ok((store, None));
2217 };
2218
2219 let boot = ClusterBootstrap {
2220 node_id: cluster.node_id,
2221 bind_address: cluster.bind_address,
2222 members: cluster.members,
2223 peers: cluster
2224 .peers
2225 .into_iter()
2226 .map(|peer| (peer.name, peer.address))
2227 .collect(),
2228 timeout: HAEMATITE_CLUSTER_OP_TIMEOUT,
2229 };
2230 let (store, responder) =
2231 HaematiteStore::open_or_create_distributed(&backend_path, shard_count, boot)
2232 .map_err(ServerError::from)?;
2233 store.materialize_all_shards().map_err(ServerError::from)?;
2234 private_root
2235 .harden_tree()
2236 .map_err(|error| private_store_mode_error(data_dir, &error))?;
2237 let store = store.retain_data_root_capability(private_root);
2238 Ok((store, Some(responder)))
2239}
2240
2241#[cfg(feature = "haematite-backend")]
2242fn private_store_mode_error(data_dir: &str, error: &std::io::Error) -> ServerError {
2243 ServerError::Config {
2244 message: format!(
2245 "failed to apply private modes under store.data_dir `{data_dir}`: {error}"
2246 ),
2247 }
2248}
2249
2250#[cfg(feature = "haematite-backend")]
2252const HAEMATITE_CLUSTER_OP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
2253
2254#[cfg(not(feature = "haematite-backend"))]
2258fn connect_haematite_store_unavailable() -> Result<ConnectedStore, ServerError> {
2259 Err(ServerError::Config {
2260 message: "store.backend = haematite requires the aion-server `haematite-backend` feature"
2261 .to_owned(),
2262 })
2263}
2264
2265#[cfg(not(feature = "liminal-transport"))]
2274#[derive(Clone, Debug)]
2275struct NullInterventionTransport;
2276
2277#[cfg(not(feature = "liminal-transport"))]
2278#[async_trait::async_trait]
2279impl crate::worker::InterventionTransport for NullInterventionTransport {
2280 async fn push(
2281 &self,
2282 _worker: &crate::worker::WorkerHandle,
2283 _command: aion_core::InterventionCommand,
2284 ) -> Result<aion_core::InterventionOutcome, ServerError> {
2285 Err(ServerError::worker_connection_lost(
2286 "intervention",
2287 "no intervention push transport is compiled in".to_owned(),
2288 ))
2289 }
2290}
2291
2292#[cfg(test)]
2293mod tests {
2294 use std::{net::SocketAddr, time::Duration};
2295
2296 use aion_store::InMemoryStore;
2297
2298 use super::ServerState;
2299 use crate::config::{
2300 AuthConfig, AuthoringConfig, DeployConfig, DevConfig, ListenConfig, MetricsConfig,
2301 NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig, OutboxConfig,
2302 RuntimeConfig, WebSocketConfig, WorkerConfig,
2303 };
2304
2305 fn runtime_config() -> RuntimeConfig {
2306 RuntimeConfig {
2307 listen: ListenConfig {
2308 grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
2309 http: SocketAddr::from(([127, 0, 0, 1], 8080)),
2310 },
2311 tls: None,
2312 auth: AuthConfig {
2313 enabled: false,
2314 jwks_url: None,
2315 jwks_refresh_seconds: 300,
2316 },
2317 ops_console: OpsConsoleConfig {
2318 source: OpsConsoleAssetSource::Embedded,
2319 },
2320 namespace: NamespaceConfig {
2321 mode: NamespaceMode::SharedEngine,
2322 },
2323 worker: WorkerConfig {
2324 heartbeat_window: Duration::from_secs(30),
2325 ..WorkerConfig::default()
2326 },
2327 websocket: WebSocketConfig {
2328 outbound_buffer_bound: 32,
2329 event_broadcast_capacity: Some(64),
2330 cluster_broadcast_capacity: Some(64),
2331 },
2332 workflow_packages: Vec::new(),
2333 deploy: DeployConfig::default(),
2334 authoring: AuthoringConfig::default(),
2335 dev: DevConfig::default(),
2336 outbox: OutboxConfig::default(),
2337 observability: crate::config::ObservabilityConfig::default(),
2338 mcp: crate::config::ResolvedMcpConfig::default(),
2339 scheduler_threads: 1,
2340 query_timeout: Some(Duration::from_secs(10)),
2341 default_namespace: "default".to_owned(),
2342 auto_create: crate::config::AutoCreate::Open,
2343 max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
2344 drain_timeout: Duration::from_secs(30),
2345 metrics: MetricsConfig { enabled: true },
2346 owned_shards: Vec::new(),
2347 cors_allowed_origins: Vec::new(),
2348 }
2349 }
2350
2351 #[test]
2362 fn engine_schema_accepts_every_attribute_the_start_writer_records()
2363 -> Result<(), Box<dyn std::error::Error>> {
2364 let schema = super::server_search_attribute_schema()?;
2365 let recorded = crate::api::handlers::workflows::start_search_attributes(
2366 "tenant-a",
2367 Some("gpu"),
2368 Some("Nightly settlement"),
2369 );
2370
2371 assert!(
2372 recorded.contains_key(crate::namespace::DISPLAY_NAME_ATTRIBUTE),
2373 "the fixture must exercise the display-name attribute, or this test \
2374 cannot see its registration go missing"
2375 );
2376 for (name, value) in &recorded {
2377 schema.validate(name, value).map_err(|error| {
2378 format!(
2379 "the start writer records {name}, but the engine's schema refuses it: {error}"
2380 )
2381 })?;
2382 }
2383 Ok(())
2384 }
2385
2386 #[tokio::test]
2387 async fn builds_state_with_in_memory_store() -> Result<(), Box<dyn std::error::Error>> {
2388 let state =
2389 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2390
2391 std::hint::black_box(state.namespace_guard());
2392 std::hint::black_box(state.worker_registry());
2393
2394 Ok(())
2395 }
2396
2397 #[tokio::test]
2405 async fn unserved_queues_surfaces_a_parked_dispatch_and_clears_it()
2406 -> Result<(), Box<dyn std::error::Error>> {
2407 use aion::{ActivityDispatch, ActivityDispatcher as _};
2408 use aion_core::{ActivityId, RunId, WorkflowId};
2409 use std::sync::Arc;
2410
2411 let state =
2412 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2413 assert!(
2414 state.unserved_queues()?.is_empty(),
2415 "a calm boot has no unserved queues"
2416 );
2417 assert!(state.queue_declarations().is_installed());
2421 assert_eq!(
2422 state
2423 .queue_declarations()
2424 .declaration_for("nobody-serves-this"),
2425 crate::worker::QueueDeclaration::Unknown
2426 );
2427
2428 let dispatcher = Arc::new(
2429 crate::worker::WorkerActivityDispatcher::new(
2430 state.worker_registry().clone(),
2431 "default",
2432 crate::worker::HeartbeatTracker::new(Duration::from_secs(5)),
2433 )
2434 .with_queue_state(state.queue_service_state().clone())
2435 .with_queue_declarations(state.queue_declarations().clone()),
2436 );
2437 let workflow_id = WorkflowId::new_v4();
2438 let request = ActivityDispatch {
2439 namespace: "default".to_owned(),
2440 task_queue: "nobody-serves-this".to_owned(),
2441 node: None,
2442 workflow_id: workflow_id.clone(),
2443 run_id: RunId::new_v4(),
2444 activity_id: ActivityId::from_sequence_position(0),
2445 name: "greet".to_owned(),
2446 input: "{}".to_owned(),
2447 config: "{}".to_owned(),
2448 attempt: 1,
2449 advisory: false,
2450 labels: std::collections::BTreeMap::new(),
2451 };
2452 let parked = std::thread::spawn(move || dispatcher.dispatch(request));
2453
2454 let mut unserved = Vec::new();
2455 for _ in 0..30 {
2456 unserved = state.unserved_queues()?;
2457 if !unserved.is_empty() {
2458 break;
2459 }
2460 tokio::time::sleep(Duration::from_millis(100)).await;
2461 }
2462 assert_eq!(unserved.len(), 1, "the parked dispatch is not surfaced");
2463 assert_eq!(
2464 unserved[0].reason,
2465 crate::worker::QueueServiceReason::NoLivePollers,
2466 "an empty catalog must not be read as a structural refusal"
2467 );
2468 assert_eq!(unserved[0].key.task_queue, "nobody-serves-this");
2469 assert_eq!(unserved[0].waiting.len(), 1);
2470 assert_eq!(unserved[0].waiting[0].workflow_id, workflow_id);
2471
2472 let (worker_tx, worker_rx) = tokio::sync::mpsc::channel(1);
2474 drop(worker_rx);
2475 let registration = state.worker_registry().register_namespaces(
2476 [String::from("default")],
2477 "nobody-serves-this",
2478 None,
2479 [String::from("greet")].iter(),
2480 worker_tx,
2481 )?;
2482 let outcome = parked.join().map_err(|_| "parked dispatch panicked")?;
2483 assert!(outcome.is_err(), "the released dispatch must resolve");
2484 assert!(
2485 state.unserved_queues()?.is_empty(),
2486 "a resolved dispatch must leave the unserved state"
2487 );
2488 registration.deregister()?;
2489 Ok(())
2490 }
2491
2492 #[tokio::test]
2493 async fn namespace_store_is_reachable_and_functional_after_default_boot()
2494 -> Result<(), Box<dyn std::error::Error>> {
2495 use aion_store::{MintOutcome, NamespaceOrigin};
2496
2497 let state =
2501 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2502
2503 let store = state.namespace_store();
2504
2505 let outcome = store
2507 .register_namespace("orders", NamespaceOrigin::WorkerMint)
2508 .await?;
2509 assert_eq!(
2510 outcome,
2511 MintOutcome::Created,
2512 "the first reference to a namespace mints it"
2513 );
2514
2515 let again = store
2517 .register_namespace("orders", NamespaceOrigin::WorkerMint)
2518 .await?;
2519 assert_eq!(
2520 again,
2521 MintOutcome::AlreadyExisted,
2522 "a second reference touches the existing record rather than re-creating it"
2523 );
2524
2525 let fetched = store.get_namespace("orders").await?;
2527 let record = fetched.ok_or("registered namespace must be retrievable via get_namespace")?;
2528 assert_eq!(record.name, "orders");
2529 assert_eq!(record.origin, NamespaceOrigin::WorkerMint);
2530
2531 let listed = store.list_namespaces().await?;
2533 assert!(
2534 listed.iter().any(|record| record.name == "orders"),
2535 "list_namespaces returns the minted namespace"
2536 );
2537
2538 Ok(())
2539 }
2540
2541 #[cfg(feature = "haematite-backend")]
2542 #[tokio::test(flavor = "multi_thread")]
2543 async fn connect_store_haematite_round_trips_through_event_store()
2544 -> Result<(), Box<dyn std::error::Error>> {
2545 use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
2546 use aion_store::WriteToken;
2547 use chrono::Utc;
2548
2549 use crate::config::{StoreBackend, StoreConfig};
2550
2551 let data_dir = crate::test_support::private_tempdir()?;
2552 let connected = super::connect_store(StoreConfig {
2556 backend: StoreBackend::Haematite,
2557 url: None,
2558 owned_shards: Vec::new(),
2559 data_dir: Some(data_dir.path().to_string_lossy().into_owned()),
2560 shard_count: 1,
2561 cluster: None,
2562 })
2563 .await?;
2564 let event_store = connected.event_store;
2565 assert!(
2566 connected.outbox_store.is_some(),
2567 "the haematite backend shares its leaf store as the dispatcher's outbox store"
2568 );
2569 assert!(
2570 connected.bootstrap_coordinator,
2571 "a single-node haematite boot owns all shards and bootstraps the coordinator"
2572 );
2573 assert!(
2574 connected.cluster_responder.is_none(),
2575 "a single-node (no [cluster]) haematite boot has no distributed responder"
2576 );
2577
2578 let workflow_id = WorkflowId::new_v4();
2579 let event = aion_core::Event::WorkflowStarted {
2580 envelope: EventEnvelope {
2581 seq: 1,
2582 recorded_at: Utc::now(),
2583 workflow_id: workflow_id.clone(),
2584 },
2585 workflow_type: String::from("checkout"),
2586 input: Payload::new(ContentType::Json, b"{}".to_vec()),
2587 run_id: RunId::new_v4(),
2588 parent_run_id: None,
2589 package_version: PackageVersion::new("a".repeat(64)),
2590 };
2591 event_store
2592 .append(
2593 WriteToken::recorder(),
2594 &workflow_id,
2595 std::slice::from_ref(&event),
2596 0,
2597 )
2598 .await?;
2599 let history = event_store.read_history(&workflow_id).await?;
2600 assert_eq!(
2601 history.len(),
2602 1,
2603 "an event appended through the server's dyn EventStore reads back"
2604 );
2605 Ok(())
2606 }
2607
2608 #[cfg(all(feature = "haematite-backend", unix))]
2609 #[test]
2610 fn haematite_root_swap_before_first_backend_touch_cannot_redirect_writes()
2611 -> Result<(), Box<dyn std::error::Error>> {
2612 use std::os::unix::fs::symlink;
2613
2614 let sandbox = crate::test_support::private_tempdir()?;
2615 let configured_root = sandbox.path().join("data");
2616 let held_root = sandbox.path().join("held-data");
2617 let outside = sandbox.path().join("outside");
2618 std::fs::create_dir(&outside)?;
2619 let configured = configured_root
2620 .to_str()
2621 .ok_or("temporary data path was not UTF-8")?;
2622
2623 let (store, responder) =
2624 super::build_haematite_store_with_hook(configured, 4, None, || {
2625 std::fs::rename(&configured_root, &held_root)?;
2630 symlink(&outside, &configured_root)?;
2631 Ok(())
2632 })?;
2633 assert!(responder.is_none());
2634
2635 let outside_entries = std::fs::read_dir(&outside)?.collect::<Result<Vec<_>, _>>()?;
2636 assert!(
2637 outside_entries.is_empty(),
2638 "Haematite followed the replaced ambient root and wrote outside"
2639 );
2640 assert!(held_root.join("config.json").is_file());
2641 for shard in 0..4 {
2642 let shard_path = held_root.join(format!("shard-{shard}"));
2643 assert!(shard_path.is_dir(), "shard {shard} was not materialized");
2644 assert!(
2645 std::fs::read_dir(&shard_path)?
2646 .next()
2647 .transpose()?
2648 .is_some(),
2649 "shard {shard} did not run Haematite's materialization path"
2650 );
2651 }
2652
2653 drop(store);
2654 Ok(())
2655 }
2656
2657 #[cfg(all(
2658 feature = "haematite-backend",
2659 any(target_os = "linux", target_os = "android")
2660 ))]
2661 #[tokio::test]
2662 async fn proc_fd_backend_path_survives_a_post_startup_root_swap()
2663 -> Result<(), Box<dyn std::error::Error>> {
2664 use std::os::unix::fs::symlink;
2665
2666 use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
2667 use aion_store::{WritableEventStore as _, WriteToken};
2668 use chrono::Utc;
2669
2670 let sandbox = crate::test_support::private_tempdir()?;
2671 let configured_root = sandbox.path().join("data");
2672 let held_root = sandbox.path().join("held-data");
2673 let capture = sandbox.path().join("capture");
2674 std::fs::create_dir(&capture)?;
2675 let configured = configured_root
2676 .to_str()
2677 .ok_or("temporary data path was not UTF-8")?;
2678
2679 let (store, responder) = super::build_haematite_store(configured, 4, None)?;
2680 assert!(responder.is_none());
2681 std::fs::rename(&configured_root, &held_root)?;
2682 symlink(&capture, &configured_root)?;
2683
2684 let workflow_id = WorkflowId::new_v4();
2685 let event = aion_core::Event::WorkflowStarted {
2686 envelope: EventEnvelope {
2687 seq: 1,
2688 recorded_at: Utc::now(),
2689 workflow_id: workflow_id.clone(),
2690 },
2691 workflow_type: String::from("post-startup-root-swap"),
2692 input: Payload::new(ContentType::Json, b"{}".to_vec()),
2693 run_id: RunId::new_v4(),
2694 parent_run_id: None,
2695 package_version: PackageVersion::new("a".repeat(64)),
2696 };
2697 store
2698 .append(
2699 WriteToken::recorder(),
2700 &workflow_id,
2701 std::slice::from_ref(&event),
2702 0,
2703 )
2704 .await?;
2705
2706 let captured = std::fs::read_dir(&capture)?.collect::<Result<Vec<_>, _>>()?;
2707 assert!(
2708 captured.is_empty(),
2709 "post-startup append followed the replacement symlink into capture"
2710 );
2711 assert!(held_root.join("config.json").is_file());
2712 drop(store);
2713 Ok(())
2714 }
2715
2716 #[cfg(all(
2717 feature = "haematite-backend",
2718 unix,
2719 not(any(target_os = "linux", target_os = "android"))
2720 ))]
2721 #[test]
2722 fn path_ambient_haematite_refuses_group_or_world_writable_ancestors()
2723 -> Result<(), Box<dyn std::error::Error>> {
2724 use std::os::unix::fs::PermissionsExt as _;
2725
2726 let sandbox = crate::test_support::private_tempdir()?;
2727 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2728
2729 for mode in [0o770, 0o1777] {
2730 let shared = sandbox.path().join(format!("shared-{mode:o}"));
2731 let data_root = shared.join("data");
2732 std::fs::create_dir(&shared)?;
2733 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(mode))?;
2734 std::fs::create_dir(&data_root)?;
2735 std::fs::set_permissions(&data_root, std::fs::Permissions::from_mode(0o700))?;
2736 let configured = data_root
2737 .to_str()
2738 .ok_or("temporary data path was not UTF-8")?;
2739
2740 let Err(error) = super::build_haematite_store(configured, 4, None) else {
2741 return Err(format!("mode {mode:04o} ancestor was accepted").into());
2742 };
2743 let message = error.to_string();
2744 let crate::ServerError::UnsafeDataRootAncestor {
2745 data_root: resolved_root,
2746 component,
2747 reason,
2748 } = error
2749 else {
2750 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
2751 };
2752 assert_eq!(resolved_root, std::fs::canonicalize(&data_root)?);
2753 assert_eq!(component, std::fs::canonicalize(&shared)?);
2754 assert!(
2755 reason.contains(&format!("mode {mode:04o}")),
2756 "unexpected reason: {reason}"
2757 );
2758 if mode & 0o1000 != 0 {
2759 assert!(reason.contains("sticky bit is not accepted"));
2760 }
2761 assert!(message.contains("private Aion home"));
2762 assert!(
2763 !data_root.join("config.json").exists(),
2764 "Haematite touched its ambient path before the refusal"
2765 );
2766 }
2767 Ok(())
2768 }
2769
2770 #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2771 #[test]
2772 fn path_ambient_haematite_refuses_mutating_allow_acl_ancestor()
2773 -> Result<(), Box<dyn std::error::Error>> {
2774 use std::os::unix::fs::PermissionsExt as _;
2775
2776 let sandbox = crate::test_support::private_tempdir()?;
2777 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2778 let shared = sandbox.path().join("acl-shared");
2779 let data_root = shared.join("data");
2780 std::fs::create_dir(&shared)?;
2781 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o700))?;
2782 let acl = "everyone allow list,search,add_file,add_subdirectory,delete_child";
2783 let status = std::process::Command::new("chmod")
2784 .arg("+a")
2785 .arg(acl)
2786 .arg(&shared)
2787 .status()?;
2788 assert!(status.success(), "failed to install Darwin regression ACL");
2789 let configured = data_root
2790 .to_str()
2791 .ok_or("temporary data path was not UTF-8")?;
2792
2793 let result = super::build_haematite_store(configured, 4, None);
2794 let cleanup = std::process::Command::new("chmod")
2795 .arg("-RN")
2796 .arg(&shared)
2797 .status()?;
2798 assert!(cleanup.success(), "failed to clean Darwin regression ACL");
2799
2800 let Err(error) = result else {
2801 return Err("mutating non-euid allow ACL ancestor was accepted".into());
2802 };
2803 let message = error.to_string();
2804 let crate::ServerError::UnsafeDataRootAncestor {
2805 component, reason, ..
2806 } = error
2807 else {
2808 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
2809 };
2810 assert_eq!(component, std::fs::canonicalize(&shared)?);
2811 assert!(
2812 reason.contains("allow"),
2813 "reason did not name the ACE: {reason}"
2814 );
2815 assert!(
2816 reason.contains("everyone"),
2817 "reason did not name the ACE principal: {reason}"
2818 );
2819 assert!(
2820 !data_root.join("config.json").exists(),
2821 "Haematite touched its ambient path before the ACL refusal"
2822 );
2823 Ok(())
2824 }
2825
2826 #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2827 #[test]
2828 fn path_ambient_haematite_accepts_the_euid_uuid_allow_ace()
2829 -> Result<(), Box<dyn std::error::Error>> {
2830 use std::os::unix::fs::PermissionsExt as _;
2831
2832 use exacl::{AclEntry, AclOption, Perm};
2833
2834 let sandbox = crate::test_support::private_tempdir()?;
2835 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2836 let private_parent = sandbox.path().join("euid-uuid-allow");
2837 let data_root = private_parent.join("data");
2838 std::fs::create_dir(&private_parent)?;
2839 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
2840
2841 let server_uid = rustix::process::geteuid().as_raw();
2842 let ace_qualifier = crate::filesystem::darwin_user_uuid_for_test(server_uid)?;
2843 let entry = AclEntry::allow_user(
2844 &ace_qualifier.to_string(),
2845 Perm::EXECUTE | Perm::WRITE | Perm::APPEND | Perm::DELETE_CHILD,
2846 None,
2847 );
2848 exacl::setfacl(
2849 &[private_parent.as_path()],
2850 &[entry],
2851 AclOption::SYMLINK_ACL,
2852 )?;
2853 let configured = data_root
2854 .to_str()
2855 .ok_or("temporary data path was not UTF-8")?;
2856
2857 let result = super::build_haematite_store(configured, 4, None);
2858 let cleanup = std::process::Command::new("chmod")
2859 .arg("-RN")
2860 .arg(&private_parent)
2861 .status()?;
2862 assert!(cleanup.success(), "failed to clean euid UUID allow ACL");
2863
2864 let (store, responder) = result?;
2865 assert!(responder.is_none());
2866 assert!(data_root.join("config.json").is_file());
2867 drop(store);
2868 Ok(())
2869 }
2870
2871 #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2872 #[test]
2873 fn path_ambient_haematite_refuses_a_non_euid_user_uuid_allow_ace()
2874 -> Result<(), Box<dyn std::error::Error>> {
2875 use std::os::unix::fs::PermissionsExt as _;
2876
2877 use exacl::{AclEntry, AclOption, Perm};
2878
2879 let sandbox = crate::test_support::private_tempdir()?;
2880 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2881 let shared = sandbox.path().join("non-euid-uuid-allow");
2882 let data_root = shared.join("data");
2883 std::fs::create_dir(&shared)?;
2884 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o700))?;
2885
2886 let server_uid = rustix::process::geteuid().as_raw();
2887 let foreign_uid = u32::from(server_uid == 0);
2888 let foreign_qualifier = crate::filesystem::darwin_user_uuid_for_test(foreign_uid)?;
2889 let entry = AclEntry::allow_user(
2890 &foreign_qualifier.to_string(),
2891 Perm::EXECUTE | Perm::WRITE | Perm::APPEND | Perm::DELETE_CHILD,
2892 None,
2893 );
2894 exacl::setfacl(&[shared.as_path()], &[entry], AclOption::SYMLINK_ACL)?;
2895 let configured = data_root
2896 .to_str()
2897 .ok_or("temporary data path was not UTF-8")?;
2898
2899 let result = super::build_haematite_store(configured, 4, None);
2900 let cleanup = std::process::Command::new("chmod")
2901 .arg("-RN")
2902 .arg(&shared)
2903 .status()?;
2904 assert!(cleanup.success(), "failed to clean non-euid UUID allow ACL");
2905
2906 let Err(error) = result else {
2907 return Err("mutating non-euid user UUID allow ACE was accepted".into());
2908 };
2909 let message = error.to_string();
2910 let crate::ServerError::UnsafeDataRootAncestor {
2911 component, reason, ..
2912 } = error
2913 else {
2914 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
2915 };
2916 assert_eq!(component, std::fs::canonicalize(&shared)?);
2917 assert!(
2918 reason.contains("allow") && reason.contains(&format!("server euid {server_uid}")),
2919 "reason did not name the rejected ACE: {reason}"
2920 );
2921 assert!(
2922 !data_root.join("config.json").exists(),
2923 "Haematite touched its ambient path before the UUID ACL refusal"
2924 );
2925 Ok(())
2926 }
2927
2928 #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2929 #[test]
2930 fn path_ambient_haematite_accepts_a_deny_only_acl_ancestor()
2931 -> Result<(), Box<dyn std::error::Error>> {
2932 use std::os::unix::fs::PermissionsExt as _;
2933
2934 let sandbox = crate::test_support::private_tempdir()?;
2935 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2936 let private_parent = sandbox.path().join("deny-only");
2937 let data_root = private_parent.join("data");
2938 std::fs::create_dir(&private_parent)?;
2939 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
2940 let status = std::process::Command::new("chmod")
2941 .arg("+a")
2942 .arg("everyone deny delete")
2943 .arg(&private_parent)
2944 .status()?;
2945 assert!(status.success(), "failed to install Darwin deny-only ACL");
2946 let configured = data_root
2947 .to_str()
2948 .ok_or("temporary data path was not UTF-8")?;
2949
2950 let result = super::build_haematite_store(configured, 4, None);
2951 let cleanup = std::process::Command::new("chmod")
2952 .arg("-RN")
2953 .arg(&private_parent)
2954 .status()?;
2955 assert!(cleanup.success(), "failed to clean Darwin deny-only ACL");
2956
2957 let (store, responder) = result?;
2958 assert!(responder.is_none());
2959 assert!(data_root.join("config.json").is_file());
2960 drop(store);
2961 Ok(())
2962 }
2963
2964 #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2965 #[test]
2966 fn path_ambient_haematite_accepts_the_stock_home_acl_chain()
2967 -> Result<(), Box<dyn std::error::Error>> {
2968 use std::os::unix::fs::PermissionsExt as _;
2969 use users::os::unix::UserExt as _;
2970
2971 let effective_uid = rustix::process::geteuid().as_raw();
2972 let effective_user = users::get_user_by_uid(effective_uid)
2973 .ok_or_else(|| format!("server euid {effective_uid} has no account record"))?;
2974 let sandbox = tempfile::Builder::new()
2975 .prefix(".aion-acl-home-proof-")
2976 .tempdir_in(effective_user.home_dir())?;
2977 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2978 let data_root = sandbox.path().join("data");
2979 let configured = data_root
2980 .to_str()
2981 .ok_or("temporary data path was not UTF-8")?;
2982
2983 let (store, responder) = super::build_haematite_store(configured, 4, None)?;
2984 assert!(responder.is_none());
2985 assert!(data_root.join("config.json").is_file());
2986 drop(store);
2987 Ok(())
2988 }
2989
2990 #[cfg(all(
2991 feature = "haematite-backend",
2992 unix,
2993 not(any(target_os = "linux", target_os = "android"))
2994 ))]
2995 #[test]
2996 fn path_ambient_haematite_accepts_an_owner_controlled_chain()
2997 -> Result<(), Box<dyn std::error::Error>> {
2998 use std::os::unix::fs::PermissionsExt as _;
2999
3000 let sandbox = crate::test_support::private_tempdir()?;
3001 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3002 let private_parent = sandbox.path().join("private");
3003 let data_root = private_parent.join("data");
3004 std::fs::create_dir(&private_parent)?;
3005 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
3006 let configured = data_root
3007 .to_str()
3008 .ok_or("temporary data path was not UTF-8")?;
3009
3010 let (store, responder) = super::build_haematite_store(configured, 4, None)?;
3011 assert!(responder.is_none());
3012 assert!(data_root.join("config.json").is_file());
3013 for shard in 0..4 {
3014 assert!(data_root.join(format!("shard-{shard}")).is_dir());
3015 }
3016 drop(store);
3017 Ok(())
3018 }
3019
3020 #[tokio::test]
3021 async fn connect_store_memory_backend_exposes_no_outbox_store()
3022 -> Result<(), Box<dyn std::error::Error>> {
3023 use crate::config::{StoreBackend, StoreConfig};
3024
3025 let connected = super::connect_store(StoreConfig {
3028 backend: StoreBackend::Memory,
3029 url: None,
3030 owned_shards: Vec::new(),
3031 data_dir: None,
3032 shard_count: 1,
3033 cluster: None,
3034 })
3035 .await?;
3036 assert!(
3037 connected.outbox_store.is_none(),
3038 "the in-memory backend exposes no outbox store"
3039 );
3040 Ok(())
3041 }
3042
3043 #[cfg(feature = "libsql-backend")]
3047 #[tokio::test]
3048 async fn connect_store_shares_outbox_store_only_for_libsql()
3049 -> Result<(), Box<dyn std::error::Error>> {
3050 use crate::config::{StoreBackend, StoreConfig};
3051
3052 let path = std::env::temp_dir().join(format!(
3057 "aion-connect-store-{}-{}.db",
3058 std::process::id(),
3059 std::time::SystemTime::now()
3060 .duration_since(std::time::UNIX_EPOCH)
3061 .map(|elapsed| elapsed.as_nanos())
3062 .unwrap_or_default()
3063 ));
3064 let connected = super::connect_store(StoreConfig {
3065 backend: StoreBackend::LibSql,
3066 url: Some(path.to_string_lossy().into_owned()),
3067 owned_shards: Vec::new(),
3068 data_dir: None,
3069 shard_count: 1,
3070 cluster: None,
3071 })
3072 .await?;
3073 assert!(
3074 connected.outbox_store.is_some(),
3075 "the libSQL backend shares its leaf store as the dispatcher's outbox store"
3076 );
3077 Ok(())
3078 }
3079
3080 #[tokio::test]
3081 async fn state_build_fails_without_event_broadcast_capacity()
3082 -> Result<(), Box<dyn std::error::Error>> {
3083 let mut runtime = runtime_config();
3084 runtime.websocket.event_broadcast_capacity = None;
3085
3086 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
3087 .await
3088 .err()
3089 .ok_or("state build must fail when event streaming is unsized")?;
3090
3091 assert!(error.is_config(), "expected a config error, got {error}");
3092 assert!(
3093 error
3094 .to_string()
3095 .contains("websocket.event_broadcast_capacity"),
3096 "error must name the missing key: {error}"
3097 );
3098 Ok(())
3099 }
3100
3101 #[tokio::test]
3102 async fn state_build_fails_without_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
3103 let mut runtime = runtime_config();
3104 runtime.query_timeout = None;
3105
3106 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
3107 .await
3108 .err()
3109 .ok_or("state build must fail when the query reply deadline is unset")?;
3110
3111 assert!(error.is_config(), "expected a config error, got {error}");
3112 assert!(
3113 error.to_string().contains("runtime.query_timeout_ms"),
3114 "error must name the missing key: {error}"
3115 );
3116 assert!(
3117 error.to_string().contains("AION_RUNTIME_QUERY_TIMEOUT_MS"),
3118 "error must name the environment override: {error}"
3119 );
3120 Ok(())
3121 }
3122
3123 #[tokio::test]
3124 async fn state_build_fails_with_zero_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
3125 let mut runtime = runtime_config();
3126 runtime.query_timeout = Some(Duration::ZERO);
3127
3128 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
3129 .await
3130 .err()
3131 .ok_or("state build must fail when the query reply deadline is zero")?;
3132
3133 assert!(error.is_config(), "expected a config error, got {error}");
3134 assert!(
3135 error.to_string().contains("runtime.query_timeout_ms"),
3136 "error must name the zero-valued key: {error}"
3137 );
3138 Ok(())
3139 }
3140
3141 #[tokio::test(flavor = "multi_thread")]
3164 async fn a_completed_check_through_the_built_dispatcher_lands_in_the_returned_slot()
3165 -> Result<(), Box<dyn std::error::Error>> {
3166 use std::collections::{BTreeMap, VecDeque};
3167 use std::sync::{Arc, Mutex};
3168
3169 use aion::ActivityDispatch;
3170 use aion_core::{ActivityId, RunId, WorkflowId};
3171 use aion_package::ActionBodyContract;
3172
3173 use crate::update_check::document::{FETCH_ACTION, FETCH_COMMAND, UPDATE_CHECK_QUEUE};
3174 use crate::worker::{DeclaredBodies, DeclaredBodyLookup, DispatchingRun};
3175
3176 struct SequencedBodies {
3179 replies: Mutex<VecDeque<DeclaredBodyLookup>>,
3180 }
3181
3182 impl DeclaredBodies for SequencedBodies {
3183 fn body_for(
3184 &self,
3185 _task_queue: &str,
3186 _action: &str,
3187 _run: DispatchingRun<'_>,
3188 ) -> DeclaredBodyLookup {
3189 let mut replies = match self.replies.lock() {
3190 Ok(replies) => replies,
3191 Err(poisoned) => poisoned.into_inner(),
3192 };
3193 replies.pop_front().unwrap_or(DeclaredBodyLookup::None)
3194 }
3195 }
3196
3197 let runtime = runtime_config();
3198 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
3199 ServerState::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
3200 );
3201 let namespace_store: Arc<dyn aion_store::NamespaceStore> =
3202 Arc::new(InMemoryStore::default());
3203 let worker_deployment_store: Arc<dyn aion_store::WorkerDeploymentStore> =
3204 Arc::new(InMemoryStore::default());
3205 let seams = super::build_worker_seams(
3206 &runtime,
3207 &cluster_publisher,
3208 &namespace_store,
3209 &worker_deployment_store,
3210 None,
3211 );
3212
3213 let fixture = concat!(
3216 env!("CARGO_MANIFEST_DIR"),
3217 "/src/update_check/fixtures/aion-cli-index.jsonl"
3218 );
3219 seams.declared_bodies.install(Arc::new(SequencedBodies {
3220 replies: Mutex::new(VecDeque::from([
3221 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
3222 command: FETCH_COMMAND.to_owned(),
3223 }),
3224 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
3225 command: format!("cat {fixture}"),
3226 }),
3227 ])),
3228 }));
3229
3230 let transcript = crate::activity_publisher::ActivityEventPublisher::new(
3231 Arc::new(aion_store::InMemoryObservabilityStore::default()),
3232 ServerState::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
3233 );
3234 let (dispatcher, _mock_registry, _attempt_owners, _workspace_root, update_status) =
3235 super::build_decorated_dispatcher(&runtime, &seams, transcript);
3236
3237 assert_eq!(
3238 update_status.last(),
3239 None,
3240 "the returned slot must start honestly empty"
3241 );
3242
3243 let dispatch = ActivityDispatch {
3244 namespace: "default".to_owned(),
3245 task_queue: UPDATE_CHECK_QUEUE.to_owned(),
3246 node: None,
3247 workflow_id: WorkflowId::new_v4(),
3248 run_id: RunId::new_v4(),
3249 activity_id: ActivityId::from_sequence_position(1),
3250 name: FETCH_ACTION.to_owned(),
3251 input: "{}".to_owned(),
3252 config: "{}".to_owned(),
3253 attempt: 1,
3254 labels: BTreeMap::new(),
3255 advisory: false,
3256 };
3257 let handle = tokio::task::spawn_blocking(move || dispatcher.dispatch(dispatch));
3258 let encoded = handle
3259 .await?
3260 .map_err(|error| format!("the check dispatch failed: {error}"))?;
3261 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
3262 assert_eq!(outcome["exit_code"], 0, "the local stand-in command ran");
3263
3264 let recorded = update_status.last().ok_or(
3265 "the completed check must land in the RETURNED slot — the one the boot path \
3266 stores and /update-status serves; an empty slot here is the disconnected-\
3267 producer mis-wire the r1 review proved unmeasured",
3268 )?;
3269 assert_eq!(recorded.latest_known, "0.13.7");
3270 Ok(())
3271 }
3272}