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 drain_state: DrainState,
61 metrics: Option<Metrics>,
62 health: Option<HealthState>,
63 activity_mock_registry: Option<ActivityMockRegistry>,
66 outbox_store: Option<Arc<dyn OutboxStore>>,
70 namespace_store: Arc<dyn NamespaceStore>,
79 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
81 worker_supervisor: Arc<WorkerSupervisor>,
86 outbox_wake: Arc<tokio::sync::Notify>,
93 cluster_publisher: crate::cluster_publisher::ClusterEventPublisher,
97 transcript_publisher: crate::activity_publisher::ActivityEventPublisher,
106 attempt_owners: crate::worker::AttemptOwnerIndex,
113 queue_service_state: crate::worker::QueueServiceState,
119 queue_declarations: crate::worker::QueueDeclarationSource,
123 update_status: crate::update_check::UpdateStatusState,
129 workspace_root: crate::worker::WorkspaceRoot,
137 cluster_self_node: Option<String>,
142 #[cfg(feature = "haematite-backend")]
147 cluster_responder: Option<aion_store_haematite::ClusterResponder>,
148 #[cfg(feature = "haematite-backend")]
151 cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
152 #[cfg(feature = "haematite-backend")]
155 watched_peers: Vec<crate::cluster::WatchedPeer>,
156 #[cfg(feature = "haematite-backend")]
161 shard_directory: Option<Arc<crate::routing::StaticShardDirectory>>,
162 #[cfg(feature = "haematite-backend")]
166 request_forwarder: Option<Arc<dyn crate::routing::RequestForwarder>>,
167 #[cfg(feature = "auth")]
168 jwks_cache: Option<JwksCache>,
169}
170
171impl ServerState {
172 const FALLBACK_CLUSTER_BROADCAST_CAPACITY: std::num::NonZeroUsize =
181 match std::num::NonZeroUsize::new(64) {
182 Some(value) => value,
183 None => std::num::NonZeroUsize::MIN,
184 };
185
186 pub async fn build(config: ServerConfig) -> Result<Self, ServerError> {
193 let (store_config, runtime) = config.into_parts();
194 let connected = connect_store(store_config).await?;
195 Self::build_with_connected_store(connected, runtime).await
196 }
197
198 pub async fn build_with_store<S>(store: S, runtime: RuntimeConfig) -> Result<Self, ServerError>
204 where
205 S: EventStore + NamespaceStore + WorkerDeploymentStore,
206 {
207 let leaf = Arc::new(store);
211 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
212 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
213 Self::build_with_connected_store(
214 ConnectedStore::local(leaf, None, namespace_store, worker_deployment_store),
215 runtime,
216 )
217 .await
218 }
219
220 async fn build_with_connected_store(
221 connected: ConnectedStore,
222 runtime: RuntimeConfig,
223 ) -> Result<Self, ServerError> {
224 let cluster_self_node = connected.cluster_self_node();
225 let outbox_store = connected.outbox_store;
226 let bootstrap_coordinator = connected.bootstrap_coordinator;
227 #[cfg(feature = "haematite-backend")]
228 let cluster_responder = connected.cluster_responder;
229 #[cfg(feature = "haematite-backend")]
230 let cluster_store = connected.cluster_store;
231 #[cfg(feature = "haematite-backend")]
232 let watched_peers = connected.watched_peers;
233 #[cfg(feature = "haematite-backend")]
237 let RoutingState {
238 shard_directory,
239 request_forwarder,
240 mint_routing,
241 } = build_routing_state(
242 cluster_store.as_ref(),
243 connected.directory_peers,
244 connected.self_node_id,
245 );
246 let (event_broadcast_capacity, query_timeout) = required_engine_seams(&runtime)?;
247 let (cluster_publisher, transcript_publisher) =
248 build_real_time_publishers(&runtime, connected.observability_store)?;
249 let (metrics, outbox_wake, instrumented_store) =
250 build_instrumented_store(&runtime, connected.event_store)?;
251 let exported_metrics = runtime.metrics.enabled.then_some(metrics.clone());
252 #[cfg(not(feature = "haematite-backend"))]
253 let mint_routing: Option<crate::namespace::NamespaceRouting> = None;
254 let seams = build_worker_seams(
255 &runtime,
256 &cluster_publisher,
257 &connected.namespace_store,
258 &connected.worker_deployment_store,
259 mint_routing,
260 );
261 let (
262 activity_dispatcher,
263 activity_mock_registry,
264 attempt_owners,
265 workspace_root,
266 update_status,
267 ) = build_decorated_dispatcher(&runtime, &seams, transcript_publisher.clone());
268
269 let engine = build_engine(EngineAssembly {
270 instrumented_store: &instrumented_store,
271 event_broadcast_capacity,
272 query_timeout,
273 activity_dispatcher,
274 active_registry: Arc::new(aion::Registry::default()),
275 bootstrap_coordinator,
276 runtime: &runtime,
277 })
278 .await?;
279 let engine = Arc::new(engine);
280 install_engine_backed_seams(&seams, &engine, runtime.outbox.enabled);
281 let resolver = NamespaceResolver::from_config(runtime.namespace.clone(), engine);
282 let worker_supervisor =
283 new_supervisor(&connected.worker_deployment_store, &cluster_publisher);
284 #[cfg(feature = "auth")]
285 let jwks_cache = build_jwks_cache(&runtime).await?;
286 Ok(Self {
287 inner: Arc::new(ServerStateInner {
288 namespace_guard: NamespaceGuard::new(resolver),
289 runtime,
290 metrics: exported_metrics,
291 worker_registry: seams.worker_registry,
292 pending_activities: seams.pending_activities,
293 heartbeat_tracker: seams.heartbeat_tracker,
294 drain_state: seams.drain_state,
295 health: Some(HealthState::new(instrumented_store, true)),
296 activity_mock_registry,
297 outbox_store,
298 namespace_store: connected.namespace_store,
299 worker_supervisor,
300 worker_deployment_store: connected.worker_deployment_store,
301 outbox_wake,
302 cluster_publisher,
303 transcript_publisher,
304 attempt_owners,
305 queue_service_state: seams.queue_service_state,
306 queue_declarations: seams.queue_declarations,
307 update_status,
308 workspace_root,
309 cluster_self_node,
310 #[cfg(feature = "haematite-backend")]
311 cluster_responder,
312 #[cfg(feature = "haematite-backend")]
313 cluster_store,
314 #[cfg(feature = "haematite-backend")]
315 watched_peers,
316 #[cfg(feature = "haematite-backend")]
317 shard_directory,
318 #[cfg(feature = "haematite-backend")]
319 request_forwarder,
320 #[cfg(feature = "auth")]
321 jwks_cache,
322 }),
323 })
324 }
325
326 #[must_use]
328 pub fn from_parts(namespace_resolver: NamespaceResolver, runtime: RuntimeConfig) -> Self {
329 Self::from_parts_with_namespace_store(
334 namespace_resolver,
335 runtime,
336 Arc::new(aion_store::InMemoryStore::default()),
337 )
338 }
339
340 #[must_use]
347 pub fn from_parts_with_namespace_store<S>(
348 namespace_resolver: NamespaceResolver,
349 runtime: RuntimeConfig,
350 store: Arc<S>,
351 ) -> Self
352 where
353 S: NamespaceStore + WorkerDeploymentStore,
354 {
355 let namespace_store: Arc<dyn NamespaceStore> = store.clone();
356 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = store;
357 Self::from_parts_with_control_stores(
358 namespace_resolver,
359 runtime,
360 namespace_store,
361 worker_deployment_store,
362 )
363 }
364
365 #[must_use]
370 pub fn from_parts_with_control_stores(
371 namespace_resolver: NamespaceResolver,
372 runtime: RuntimeConfig,
373 namespace_store: Arc<dyn NamespaceStore>,
374 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
375 ) -> Self {
376 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
377 let pending_activities =
384 PendingActivities::default().with_heartbeat_window(runtime.worker.heartbeat_window);
385 let bounds = transcript_bounds(&runtime);
389 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
390 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
391 );
392 Self {
393 inner: Arc::new(ServerStateInner {
394 namespace_guard: NamespaceGuard::new(namespace_resolver),
395 runtime,
396 worker_registry: ConnectedWorkerRegistry::default()
397 .with_worker_deployment_store(worker_deployment_store.clone())
398 .with_cluster_publisher(cluster_publisher.clone()),
399 pending_activities,
400 heartbeat_tracker,
401 drain_state: DrainState::default(),
402 metrics: None,
403 health: None,
404 activity_mock_registry: None,
405 outbox_store: None,
406 namespace_store,
407 worker_supervisor: new_supervisor(&worker_deployment_store, &cluster_publisher),
408 worker_deployment_store,
409 outbox_wake: Arc::new(tokio::sync::Notify::new()),
410 cluster_publisher,
411 transcript_publisher: build_transcript_publisher(
415 None,
416 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
417 bounds,
418 ),
419 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
420 queue_service_state: crate::worker::QueueServiceState::default(),
421 queue_declarations: crate::worker::QueueDeclarationSource::default(),
422 update_status: crate::update_check::UpdateStatusState::default(),
426 workspace_root: crate::worker::WorkspaceRoot::resolve(),
430 cluster_self_node: None,
431 #[cfg(feature = "haematite-backend")]
432 cluster_responder: None,
433 #[cfg(feature = "haematite-backend")]
434 cluster_store: None,
435 #[cfg(feature = "haematite-backend")]
436 watched_peers: Vec::new(),
437 #[cfg(feature = "haematite-backend")]
438 shard_directory: None,
439 #[cfg(feature = "haematite-backend")]
440 request_forwarder: None,
441 #[cfg(feature = "auth")]
442 jwks_cache: None,
443 }),
444 }
445 }
446
447 #[cfg(feature = "auth")]
456 #[must_use]
457 pub fn from_parts_with_namespace_store_and_jwks<S>(
458 namespace_resolver: NamespaceResolver,
459 runtime: RuntimeConfig,
460 store: Arc<S>,
461 jwks_cache: JwksCache,
462 ) -> Self
463 where
464 S: NamespaceStore + WorkerDeploymentStore,
465 {
466 let namespace_store: Arc<dyn NamespaceStore> = store.clone();
467 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = store;
468 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
469 let pending_activities =
476 PendingActivities::default().with_heartbeat_window(runtime.worker.heartbeat_window);
477 let bounds = transcript_bounds(&runtime);
481 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
482 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
483 );
484 Self {
485 inner: Arc::new(ServerStateInner {
486 namespace_guard: NamespaceGuard::new(namespace_resolver),
487 runtime,
488 worker_registry: ConnectedWorkerRegistry::default()
489 .with_worker_deployment_store(worker_deployment_store.clone())
490 .with_cluster_publisher(cluster_publisher.clone()),
491 pending_activities,
492 heartbeat_tracker,
493 drain_state: DrainState::default(),
494 metrics: None,
495 health: None,
496 activity_mock_registry: None,
497 outbox_store: None,
498 namespace_store,
499 worker_supervisor: new_supervisor(&worker_deployment_store, &cluster_publisher),
500 worker_deployment_store,
501 outbox_wake: Arc::new(tokio::sync::Notify::new()),
502 cluster_publisher,
503 transcript_publisher: build_transcript_publisher(
507 None,
508 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
509 bounds,
510 ),
511 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
512 queue_service_state: crate::worker::QueueServiceState::default(),
513 queue_declarations: crate::worker::QueueDeclarationSource::default(),
514 update_status: crate::update_check::UpdateStatusState::default(),
518 workspace_root: crate::worker::WorkspaceRoot::resolve(),
522 cluster_self_node: None,
523 #[cfg(feature = "haematite-backend")]
524 cluster_responder: None,
525 #[cfg(feature = "haematite-backend")]
526 cluster_store: None,
527 #[cfg(feature = "haematite-backend")]
528 watched_peers: Vec::new(),
529 #[cfg(feature = "haematite-backend")]
530 shard_directory: None,
531 #[cfg(feature = "haematite-backend")]
532 request_forwarder: None,
533 jwks_cache: Some(jwks_cache),
534 }),
535 }
536 }
537
538 #[cfg(feature = "auth")]
544 #[must_use]
545 pub fn from_parts_with_jwks(
546 namespace_resolver: NamespaceResolver,
547 runtime: RuntimeConfig,
548 jwks_cache: JwksCache,
549 ) -> Self {
550 let fallback_deployment_store: Arc<dyn WorkerDeploymentStore> =
554 Arc::new(aion_store::InMemoryStore::default());
555 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
556 let pending_activities =
563 PendingActivities::default().with_heartbeat_window(runtime.worker.heartbeat_window);
564 let bounds = transcript_bounds(&runtime);
568 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
569 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
570 );
571 Self {
572 inner: Arc::new(ServerStateInner {
573 namespace_guard: NamespaceGuard::new(namespace_resolver),
574 runtime,
575 worker_registry: ConnectedWorkerRegistry::default(),
576 pending_activities,
577 heartbeat_tracker,
578 drain_state: DrainState::default(),
579 metrics: None,
580 health: None,
581 activity_mock_registry: None,
582 outbox_store: None,
583 namespace_store: Arc::new(aion_store::InMemoryStore::default()),
588 worker_supervisor: new_supervisor(&fallback_deployment_store, &cluster_publisher),
589 worker_deployment_store: fallback_deployment_store,
590 outbox_wake: Arc::new(tokio::sync::Notify::new()),
591 cluster_publisher,
592 transcript_publisher: build_transcript_publisher(
596 None,
597 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
598 bounds,
599 ),
600 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
601 queue_service_state: crate::worker::QueueServiceState::default(),
602 queue_declarations: crate::worker::QueueDeclarationSource::default(),
603 update_status: crate::update_check::UpdateStatusState::default(),
607 workspace_root: crate::worker::WorkspaceRoot::resolve(),
611 cluster_self_node: None,
612 #[cfg(feature = "haematite-backend")]
613 cluster_responder: None,
614 #[cfg(feature = "haematite-backend")]
615 cluster_store: None,
616 #[cfg(feature = "haematite-backend")]
617 watched_peers: Vec::new(),
618 #[cfg(feature = "haematite-backend")]
619 shard_directory: None,
620 #[cfg(feature = "haematite-backend")]
621 request_forwarder: None,
622 jwks_cache: Some(jwks_cache),
623 }),
624 }
625 }
626
627 #[must_use]
629 pub fn from_parts_with_registry(
630 namespace_resolver: NamespaceResolver,
631 runtime: RuntimeConfig,
632 worker_registry: ConnectedWorkerRegistry,
633 ) -> Self {
634 let fallback_deployment_store: Arc<dyn WorkerDeploymentStore> =
638 Arc::new(aion_store::InMemoryStore::default());
639 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
640 let pending_activities =
647 PendingActivities::default().with_heartbeat_window(runtime.worker.heartbeat_window);
648 let bounds = transcript_bounds(&runtime);
652 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
653 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
654 );
655 Self {
656 inner: Arc::new(ServerStateInner {
657 namespace_guard: NamespaceGuard::new(namespace_resolver),
658 runtime,
659 worker_registry,
660 pending_activities,
661 heartbeat_tracker,
662 drain_state: DrainState::default(),
663 metrics: None,
664 health: None,
665 activity_mock_registry: None,
666 outbox_store: None,
667 namespace_store: Arc::new(aion_store::InMemoryStore::default()),
672 worker_supervisor: new_supervisor(&fallback_deployment_store, &cluster_publisher),
673 worker_deployment_store: fallback_deployment_store,
674 outbox_wake: Arc::new(tokio::sync::Notify::new()),
675 cluster_publisher,
676 transcript_publisher: build_transcript_publisher(
680 None,
681 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
682 bounds,
683 ),
684 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
685 queue_service_state: crate::worker::QueueServiceState::default(),
686 queue_declarations: crate::worker::QueueDeclarationSource::default(),
687 update_status: crate::update_check::UpdateStatusState::default(),
691 workspace_root: crate::worker::WorkspaceRoot::resolve(),
695 cluster_self_node: None,
696 #[cfg(feature = "haematite-backend")]
697 cluster_responder: None,
698 #[cfg(feature = "haematite-backend")]
699 cluster_store: None,
700 #[cfg(feature = "haematite-backend")]
701 watched_peers: Vec::new(),
702 #[cfg(feature = "haematite-backend")]
703 shard_directory: None,
704 #[cfg(feature = "haematite-backend")]
705 request_forwarder: None,
706 #[cfg(feature = "auth")]
707 jwks_cache: None,
708 }),
709 }
710 }
711
712 #[must_use]
714 pub fn namespace_guard(&self) -> &NamespaceGuard {
715 &self.inner.namespace_guard
716 }
717
718 #[must_use]
720 pub fn deploy_guard(&self) -> crate::deploy::DeployGuard {
721 crate::deploy::DeployGuard::new(self.inner.namespace_guard.resolver().clone())
722 }
723
724 #[must_use]
726 pub fn runtime_config(&self) -> &RuntimeConfig {
727 &self.inner.runtime
728 }
729
730 #[must_use]
738 pub(crate) fn update_status(&self) -> &crate::update_check::UpdateStatusState {
739 &self.inner.update_status
740 }
741
742 #[must_use]
752 pub fn workspace_root(&self) -> &crate::worker::WorkspaceRoot {
753 &self.inner.workspace_root
754 }
755
756 #[must_use]
758 pub fn worker_registry(&self) -> &ConnectedWorkerRegistry {
759 &self.inner.worker_registry
760 }
761
762 #[must_use]
766 pub fn cluster_publisher(&self) -> &crate::cluster_publisher::ClusterEventPublisher {
767 &self.inner.cluster_publisher
768 }
769
770 #[must_use]
775 pub fn transcript_publisher(&self) -> &crate::activity_publisher::ActivityEventPublisher {
776 &self.inner.transcript_publisher
777 }
778
779 #[must_use]
783 pub fn attempt_owners(&self) -> &crate::worker::AttemptOwnerIndex {
784 &self.inner.attempt_owners
785 }
786
787 #[must_use]
790 pub fn queue_service_state(&self) -> &crate::worker::QueueServiceState {
791 &self.inner.queue_service_state
792 }
793
794 #[must_use]
798 pub fn queue_declarations(&self) -> &crate::worker::QueueDeclarationSource {
799 &self.inner.queue_declarations
800 }
801
802 pub fn unserved_queues(&self) -> Result<Vec<crate::worker::UnservedQueue>, ServerError> {
813 self.inner.queue_service_state.unserved()
814 }
815
816 pub fn unrecoverable_runs(
834 &self,
835 ) -> Result<Vec<(aion_core::WorkflowId, aion::registry::UnrecoverableRun)>, ServerError> {
836 self.engine()?
837 .registry()
838 .unrecoverable()
839 .list()
840 .map_err(ServerError::from)
841 }
842
843 #[must_use]
855 pub fn intervention_router(&self) -> crate::worker::InterventionRouter {
856 let transport: std::sync::Arc<dyn crate::worker::InterventionTransport> = {
857 #[cfg(feature = "liminal-transport")]
858 {
859 std::sync::Arc::new(crate::worker::LiminalInterventionTransport)
860 }
861 #[cfg(not(feature = "liminal-transport"))]
862 {
863 std::sync::Arc::new(NullInterventionTransport)
864 }
865 };
866 crate::worker::InterventionRouter::new(
867 self.inner.worker_registry.clone(),
868 self.inner.attempt_owners.clone(),
869 transport,
870 )
871 .with_transcript_publisher(self.inner.transcript_publisher.clone())
874 }
875
876 #[must_use]
880 pub fn cluster_self_node(&self) -> Option<&str> {
881 self.inner.cluster_self_node.as_deref()
882 }
883
884 pub fn engine(&self) -> Result<Arc<aion::Engine>, ServerError> {
897 self.inner
898 .namespace_guard
899 .resolver()
900 .engine()
901 .map(Arc::clone)
902 }
903
904 #[must_use]
906 pub fn pending_activities(&self) -> &PendingActivities {
907 &self.inner.pending_activities
908 }
909
910 #[must_use]
912 pub fn heartbeat_tracker(&self) -> &HeartbeatTracker {
913 &self.inner.heartbeat_tracker
914 }
915
916 #[must_use]
918 pub fn drain_state(&self) -> &DrainState {
919 &self.inner.drain_state
920 }
921
922 #[must_use]
924 pub fn metrics(&self) -> Option<&Metrics> {
925 self.inner.metrics.as_ref()
926 }
927
928 #[must_use]
930 pub fn health(&self) -> Option<&HealthState> {
931 self.inner.health.as_ref()
932 }
933
934 #[must_use]
939 pub fn activity_mock_registry(&self) -> Option<&ActivityMockRegistry> {
940 self.inner.activity_mock_registry.as_ref()
941 }
942
943 #[must_use]
949 pub fn outbox_store(&self) -> Option<Arc<dyn OutboxStore>> {
950 self.inner.outbox_store.clone()
951 }
952
953 #[must_use]
962 pub fn namespace_store(&self) -> &Arc<dyn NamespaceStore> {
963 &self.inner.namespace_store
964 }
965
966 #[must_use]
968 pub fn worker_deployment_store(&self) -> &Arc<dyn WorkerDeploymentStore> {
969 &self.inner.worker_deployment_store
970 }
971
972 #[must_use]
979 pub fn worker_supervisor(&self) -> &Arc<WorkerSupervisor> {
980 &self.inner.worker_supervisor
981 }
982
983 #[must_use]
992 pub fn namespace_minter(&self) -> NamespaceMinter {
993 let minter = NamespaceMinter::new(
994 Arc::clone(&self.inner.namespace_store),
995 self.inner.runtime.auto_create,
996 )
997 .with_cluster_publisher(self.inner.cluster_publisher.clone());
1002 match self.namespace_routing() {
1007 Some(routing) => minter.with_routing(routing),
1008 None => minter,
1009 }
1010 }
1011
1012 #[cfg(feature = "haematite-backend")]
1022 #[must_use]
1023 pub fn namespace_routing(&self) -> Option<crate::namespace::NamespaceRouting> {
1024 build_namespace_routing(
1025 self.cluster_store(),
1026 self.shard_directory(),
1027 self.request_forwarder(),
1028 )
1029 }
1030
1031 #[cfg(not(feature = "haematite-backend"))]
1033 #[must_use]
1034 pub const fn namespace_routing(&self) -> Option<crate::namespace::NamespaceRouting> {
1035 None
1036 }
1037
1038 #[must_use]
1044 pub fn outbox_wake(&self) -> Arc<tokio::sync::Notify> {
1045 Arc::clone(&self.inner.outbox_wake)
1046 }
1047
1048 #[cfg(feature = "haematite-backend")]
1054 #[must_use]
1055 pub fn is_clustered(&self) -> bool {
1056 self.inner.cluster_responder.is_some()
1057 }
1058
1059 #[cfg(feature = "haematite-backend")]
1064 #[must_use]
1065 pub fn cluster_store(&self) -> Option<&Arc<aion_store_haematite::HaematiteStore>> {
1066 self.inner.cluster_store.as_ref()
1067 }
1068
1069 #[cfg(feature = "haematite-backend")]
1073 #[must_use]
1074 pub fn shard_directory(&self) -> Option<&Arc<crate::routing::StaticShardDirectory>> {
1075 self.inner.shard_directory.as_ref()
1076 }
1077
1078 #[cfg(feature = "haematite-backend")]
1081 #[must_use]
1082 pub fn request_forwarder(&self) -> Option<&Arc<dyn crate::routing::RequestForwarder>> {
1083 self.inner.request_forwarder.as_ref()
1084 }
1085
1086 #[must_use]
1103 pub fn spawn_heartbeat_sweeper(
1104 &self,
1105 shutdown: tokio::sync::watch::Receiver<bool>,
1106 ) -> tokio::task::JoinHandle<()> {
1107 let sweeper = crate::worker::HeartbeatSweeper::new(
1108 self.inner.heartbeat_tracker.clone(),
1109 self.inner.worker_registry.clone(),
1110 self.inner.pending_activities.clone(),
1111 self.inner.drain_state.clone(),
1112 self.inner.runtime.worker.heartbeat_window,
1113 )
1114 .with_queue_state(self.inner.queue_service_state.clone());
1118 tokio::spawn(sweeper.run(shutdown))
1119 }
1120
1121 #[cfg(feature = "liminal-transport")]
1132 #[must_use]
1133 pub fn spawn_liminal_liveness_probe(
1134 &self,
1135 notifier: std::sync::Arc<crate::worker::LiminalConnectionNotifier>,
1136 shutdown: tokio::sync::watch::Receiver<bool>,
1137 ) -> tokio::task::JoinHandle<()> {
1138 let probe = crate::worker::LivenessProbe::new(
1139 notifier,
1140 self.inner.heartbeat_tracker.clone(),
1141 self.inner.worker_registry.clone(),
1142 self.inner.runtime.worker.heartbeat_window,
1143 );
1144 tokio::spawn(probe.run(shutdown))
1145 }
1146
1147 #[cfg(feature = "haematite-backend")]
1163 pub fn spawn_cluster_supervisor(
1164 &self,
1165 config: crate::cluster::SupervisorConfig,
1166 shutdown: tokio::sync::watch::Receiver<bool>,
1167 ) -> Result<bool, ServerError> {
1168 let Some(cluster_store) = self.inner.cluster_store.clone() else {
1169 return Ok(false);
1170 };
1171 if self.inner.watched_peers.is_empty() {
1172 return Ok(false);
1173 }
1174 let engine = Arc::clone(self.inner.namespace_guard.resolver().engine()?);
1175 let publisher = Arc::new(self.inner.cluster_publisher.clone());
1179 let self_node = self.inner.cluster_self_node.clone().unwrap_or_default();
1180 let adopter = Arc::new(crate::cluster::OutboxSettlingAdopter::new(
1186 engine,
1187 self.inner.outbox_store.clone(),
1188 ));
1189 let supervisor = crate::cluster::ClusterSupervisor::new(
1190 cluster_store,
1191 adopter,
1192 self.inner.watched_peers.clone(),
1193 config,
1194 )
1195 .with_publisher(publisher, self_node);
1196 if !supervisor.watches_any() {
1197 return Ok(false);
1198 }
1199 tokio::spawn(supervisor.run(shutdown));
1200 Ok(true)
1201 }
1202
1203 #[cfg(feature = "auth")]
1205 #[must_use]
1206 pub fn jwks_cache(&self) -> Option<&JwksCache> {
1207 self.inner.jwks_cache.as_ref()
1208 }
1209
1210 pub fn shutdown(&self) -> Result<(), ServerError> {
1217 self.inner.namespace_guard.resolver().shutdown_engine()
1218 }
1219}
1220
1221#[cfg(feature = "auth")]
1222async fn build_jwks_cache(runtime: &RuntimeConfig) -> Result<Option<JwksCache>, ServerError> {
1223 if !runtime.auth.enabled {
1224 return Ok(None);
1225 }
1226 let Some(url) = runtime.auth.jwks_url.clone() else {
1227 return Err(ServerError::Config {
1228 message: "auth.jwks_url must not be empty when auth.enabled is true".to_owned(),
1229 });
1230 };
1231 let interval = std::time::Duration::from_secs(runtime.auth.jwks_refresh_seconds);
1232 let cache = JwksCache::new(url, interval)
1233 .await
1234 .map_err(|error| ServerError::Config {
1235 message: format!("auth jwks initial fetch failed: {error}"),
1236 })?;
1237 Ok(Some(cache))
1238}
1239
1240fn metrics_config_error(error: &MetricsError) -> ServerError {
1241 ServerError::Config {
1242 message: error.to_string(),
1243 }
1244}
1245
1246struct EngineAssembly<'a> {
1248 instrumented_store: &'a Arc<InstrumentedEventStore>,
1250 event_broadcast_capacity: std::num::NonZeroUsize,
1252 query_timeout: std::time::Duration,
1254 activity_dispatcher: Arc<dyn ActivityDispatcher>,
1256 active_registry: Arc<aion::Registry>,
1258 bootstrap_coordinator: bool,
1260 runtime: &'a RuntimeConfig,
1262}
1263
1264async fn build_engine(assembly: EngineAssembly<'_>) -> Result<aion::Engine, ServerError> {
1271 let mut search_attribute_schema = aion_core::SearchAttributeSchema::new();
1272 search_attribute_schema
1273 .register(
1274 crate::namespace::NAMESPACE_ATTRIBUTE,
1275 aion_core::SearchAttributeType::String,
1276 )
1277 .map_err(|error| ServerError::Config {
1278 message: format!("failed to register namespace search attribute: {error}"),
1279 })?;
1280 search_attribute_schema
1281 .register(
1282 crate::namespace::TASK_QUEUE_ATTRIBUTE,
1283 aion_core::SearchAttributeType::String,
1284 )
1285 .map_err(|error| ServerError::Config {
1286 message: format!("failed to register task_queue search attribute: {error}"),
1287 })?;
1288 let runtime = assembly.runtime;
1289 let builder = EngineBuilder::new()
1290 .store_arc(assembly.instrumented_store.clone())
1291 .event_streaming(assembly.event_broadcast_capacity)
1292 .in_memory_visibility()
1293 .search_attribute_schema(search_attribute_schema)
1294 .scheduler_threads(runtime.scheduler_threads)
1295 .outbox_enabled(runtime.outbox.enabled)
1296 .activity_dispatcher(assembly.activity_dispatcher)
1297 .active_registry(assembly.active_registry)
1298 .production_recovery_seam()
1299 .signal_router_factory(|runtime: Arc<RuntimeHandle>, handoff| {
1300 Arc::new(ConcreteSignalRouter::new(runtime, handoff)) as Arc<dyn SignalRouter>
1301 })
1302 .query_timeout(assembly.query_timeout)
1303 .bootstrap_schedule_coordinator(assembly.bootstrap_coordinator)
1309 .load_workflow_sources(runtime.workflow_packages.iter().map(PathBuf::as_path));
1310 let builder = if runtime.owned_shards.is_empty() {
1316 builder
1317 } else {
1318 builder.owned_shards(runtime.owned_shards.iter().copied())
1319 };
1320 builder.build().await.map_err(ServerError::from)
1321}
1322
1323fn required_engine_seams(
1328 runtime: &RuntimeConfig,
1329) -> Result<(std::num::NonZeroUsize, std::time::Duration), ServerError> {
1330 let event_broadcast_capacity = runtime
1331 .websocket
1332 .event_broadcast_capacity
1333 .and_then(std::num::NonZeroUsize::new)
1334 .ok_or_else(|| ServerError::Config {
1335 message: crate::config::EVENT_BROADCAST_CAPACITY_REQUIRED.to_owned(),
1336 })?;
1337 let query_timeout = runtime
1338 .query_timeout
1339 .filter(|timeout| !timeout.is_zero())
1340 .ok_or_else(|| ServerError::Config {
1341 message: crate::config::QUERY_TIMEOUT_REQUIRED.to_owned(),
1342 })?;
1343 Ok((event_broadcast_capacity, query_timeout))
1344}
1345
1346fn install_outbox_delivery(
1353 pending_activities: &PendingActivities,
1354 engine: &Arc<aion::Engine>,
1355 outbox_enabled: bool,
1356) {
1357 if outbox_enabled {
1358 let callback = Arc::new(crate::worker::ServerOutboxDeliveryCallback::new(
1359 Arc::clone(engine),
1360 ));
1361 pending_activities.set_outbox_delivery(callback);
1362 }
1363}
1364
1365struct WorkerSeams {
1367 worker_registry: ConnectedWorkerRegistry,
1368 pending_activities: PendingActivities,
1369 heartbeat_tracker: HeartbeatTracker,
1370 drain_state: DrainState,
1371 queue_declarations: crate::worker::QueueDeclarationSource,
1372 queue_service_state: crate::worker::QueueServiceState,
1373 declared_bodies: crate::worker::DeclaredBodySource,
1374}
1375
1376fn build_worker_seams(
1381 runtime: &RuntimeConfig,
1382 cluster_publisher: &crate::cluster_publisher::ClusterEventPublisher,
1383 namespace_store: &Arc<dyn NamespaceStore>,
1384 worker_deployment_store: &Arc<dyn WorkerDeploymentStore>,
1385 mint_routing: Option<crate::namespace::NamespaceRouting>,
1386) -> WorkerSeams {
1387 let worker_registry = ConnectedWorkerRegistry::default()
1388 .with_cluster_publisher(cluster_publisher.clone())
1389 .with_namespace_minting(namespace_store.clone(), runtime.auto_create)
1390 .with_worker_deployment_store(worker_deployment_store.clone());
1391 let worker_registry = match mint_routing {
1397 Some(routing) => worker_registry.with_namespace_routing(routing),
1398 None => worker_registry,
1399 };
1400 WorkerSeams {
1401 worker_registry,
1402 pending_activities: PendingActivities::default()
1407 .with_heartbeat_window(runtime.worker.heartbeat_window),
1408 heartbeat_tracker: HeartbeatTracker::new(runtime.worker.heartbeat_window),
1409 drain_state: DrainState::default(),
1410 queue_declarations: crate::worker::QueueDeclarationSource::default(),
1411 queue_service_state: crate::worker::QueueServiceState::default(),
1412 declared_bodies: crate::worker::DeclaredBodySource::default(),
1413 }
1414}
1415
1416fn install_queue_declarations(
1423 queue_declarations: &crate::worker::QueueDeclarationSource,
1424 engine: &Arc<aion::Engine>,
1425) {
1426 queue_declarations.install(Arc::new(crate::worker::EngineQueueDeclarations::new(
1427 Arc::clone(engine),
1428 )));
1429}
1430
1431fn install_declared_bodies(
1439 declared_bodies: &crate::worker::DeclaredBodySource,
1440 engine: &Arc<aion::Engine>,
1441) {
1442 declared_bodies.install(Arc::new(crate::worker::EngineDeclaredBodies::new(
1443 Arc::clone(engine),
1444 )));
1445}
1446
1447fn install_engine_backed_seams(seams: &WorkerSeams, engine: &Arc<aion::Engine>, outbox: bool) {
1453 install_outbox_delivery(&seams.pending_activities, engine, outbox);
1454 install_queue_declarations(&seams.queue_declarations, engine);
1455 install_declared_bodies(&seams.declared_bodies, engine);
1456}
1457
1458fn build_bridge_dispatcher(
1471 runtime: &RuntimeConfig,
1472 seams: &WorkerSeams,
1473) -> (WorkerActivityDispatcher, crate::worker::AttemptOwnerIndex) {
1474 let attempt_owners = crate::worker::AttemptOwnerIndex::new();
1475 let dispatcher = WorkerActivityDispatcher::new(
1476 seams.worker_registry.clone(),
1477 runtime.default_namespace.clone(),
1478 seams.heartbeat_tracker.clone(),
1479 )
1480 .with_pending(seams.pending_activities.clone())
1481 .with_drain_state(seams.drain_state.clone())
1482 .with_tokio_handle(tokio::runtime::Handle::current())
1483 .with_attempt_owners(attempt_owners.clone())
1484 .with_queue_service(runtime.worker.queue_service.clone())
1485 .with_queue_declarations(seams.queue_declarations.clone())
1486 .with_queue_state(seams.queue_service_state.clone());
1487 (dispatcher, attempt_owners)
1488}
1489
1490fn build_instrumented_store(
1503 runtime: &RuntimeConfig,
1504 event_store: Arc<dyn EventStore>,
1505) -> Result<
1506 (
1507 Metrics,
1508 Arc<tokio::sync::Notify>,
1509 Arc<InstrumentedEventStore>,
1510 ),
1511 ServerError,
1512> {
1513 let metrics = Metrics::new().map_err(|error| metrics_config_error(&error))?;
1514 let outbox_wake = Arc::new(tokio::sync::Notify::new());
1515 let instrumented_store = Arc::new(
1516 InstrumentedEventStore::new(
1517 event_store,
1518 metrics.clone(),
1519 runtime.default_namespace.clone(),
1520 )
1521 .with_outbox_wake(Arc::clone(&outbox_wake)),
1522 );
1523 Ok((metrics, outbox_wake, instrumented_store))
1524}
1525
1526fn build_decorated_dispatcher(
1531 runtime: &RuntimeConfig,
1532 seams: &WorkerSeams,
1533 transcript: crate::activity_publisher::ActivityEventPublisher,
1534) -> (
1535 Arc<dyn ActivityDispatcher>,
1536 Option<ActivityMockRegistry>,
1537 crate::worker::AttemptOwnerIndex,
1538 crate::worker::WorkspaceRoot,
1539 crate::update_check::UpdateStatusState,
1540) {
1541 let workspace_root = crate::worker::WorkspaceRoot::resolve();
1545 let update_status = crate::update_check::UpdateStatusState::default();
1548 let (dispatcher, attempt_owners) = build_bridge_dispatcher(runtime, seams);
1549 let (activity_dispatcher, activity_mock_registry) = decorate_activity_dispatcher(
1550 dispatcher,
1551 seams.declared_bodies.clone(),
1552 workspace_root.clone(),
1553 transcript,
1554 update_status.clone(),
1555 runtime.dev.enabled,
1556 );
1557 (
1558 activity_dispatcher,
1559 activity_mock_registry,
1560 attempt_owners,
1561 workspace_root,
1562 update_status,
1563 )
1564}
1565
1566fn decorate_activity_dispatcher(
1567 dispatcher: WorkerActivityDispatcher,
1568 declared_bodies: crate::worker::DeclaredBodySource,
1569 workspace_root: crate::worker::WorkspaceRoot,
1570 transcript: crate::activity_publisher::ActivityEventPublisher,
1571 update_status: crate::update_check::UpdateStatusState,
1572 dev_enabled: bool,
1573) -> (Arc<dyn ActivityDispatcher>, Option<ActivityMockRegistry>) {
1574 let declared = crate::worker::DeclaredCommandDispatcher::new(
1582 Arc::new(dispatcher),
1583 declared_bodies.clone(),
1584 tokio::runtime::Handle::current(),
1585 workspace_root,
1586 transcript,
1587 );
1588 let observed = crate::update_check::UpdateCheckObserver::new(
1589 Arc::new(declared),
1590 declared_bodies,
1591 update_status,
1592 );
1593 if dev_enabled {
1594 let registry = ActivityMockRegistry::new();
1595 let decorated = DevMockingDispatcher::new(Arc::new(observed), registry.clone());
1596 (Arc::new(decorated), Some(registry))
1597 } else {
1598 (Arc::new(observed), None)
1599 }
1600}
1601
1602fn required_cluster_broadcast_capacity(
1607 runtime: &RuntimeConfig,
1608) -> Result<std::num::NonZeroUsize, ServerError> {
1609 runtime
1610 .websocket
1611 .cluster_broadcast_capacity
1612 .and_then(std::num::NonZeroUsize::new)
1613 .ok_or_else(|| ServerError::Config {
1614 message: crate::config::CLUSTER_BROADCAST_CAPACITY_REQUIRED.to_owned(),
1615 })
1616}
1617
1618fn build_real_time_publishers(
1631 runtime: &RuntimeConfig,
1632 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1633) -> Result<
1634 (
1635 crate::cluster_publisher::ClusterEventPublisher,
1636 crate::activity_publisher::ActivityEventPublisher,
1637 ),
1638 ServerError,
1639> {
1640 let capacity = required_cluster_broadcast_capacity(runtime)?;
1641 Ok((
1642 crate::cluster_publisher::ClusterEventPublisher::new(capacity),
1643 build_transcript_publisher(observability_store, capacity, transcript_bounds(runtime)),
1644 ))
1645}
1646
1647fn transcript_bounds(runtime: &RuntimeConfig) -> crate::activity_bounds::TranscriptBounds {
1649 crate::activity_bounds::TranscriptBounds {
1650 max_event_bytes: runtime.observability.max_event_bytes,
1651 max_stream_events: runtime.observability.max_stream_events,
1652 }
1653}
1654
1655fn build_transcript_publisher(
1666 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1667 capacity: std::num::NonZeroUsize,
1668 bounds: crate::activity_bounds::TranscriptBounds,
1669) -> crate::activity_publisher::ActivityEventPublisher {
1670 let store = observability_store
1671 .unwrap_or_else(|| Arc::new(aion_store::InMemoryObservabilityStore::default()));
1672 crate::activity_publisher::ActivityEventPublisher::new(store, capacity).with_bounds(bounds)
1673}
1674
1675#[cfg(feature = "haematite-backend")]
1677struct RoutingState {
1678 shard_directory: Option<Arc<crate::routing::StaticShardDirectory>>,
1679 request_forwarder: Option<Arc<dyn crate::routing::RequestForwarder>>,
1680 mint_routing: Option<crate::namespace::NamespaceRouting>,
1685}
1686
1687#[cfg(feature = "haematite-backend")]
1691fn build_routing_state(
1692 cluster_store: Option<&Arc<aion_store_haematite::HaematiteStore>>,
1693 directory_peers: Vec<crate::routing::DirectoryPeer>,
1694 self_node_id: Option<String>,
1695) -> RoutingState {
1696 let Some(store) = cluster_store else {
1697 return RoutingState {
1698 shard_directory: None,
1699 request_forwarder: None,
1700 mint_routing: None,
1701 };
1702 };
1703 let shard_directory = Arc::new(crate::routing::StaticShardDirectory::new(
1704 Arc::clone(store),
1705 directory_peers,
1706 self_node_id,
1707 ));
1708 let request_forwarder: Arc<dyn crate::routing::RequestForwarder> =
1709 Arc::new(crate::routing::GrpcRequestForwarder::new());
1710 let mint_routing = build_namespace_routing(
1711 Some(store),
1712 Some(&shard_directory),
1713 Some(&request_forwarder),
1714 );
1715 RoutingState {
1716 shard_directory: Some(shard_directory),
1717 request_forwarder: Some(request_forwarder),
1718 mint_routing,
1719 }
1720}
1721
1722#[cfg(feature = "haematite-backend")]
1732fn build_namespace_routing(
1733 cluster_store: Option<&Arc<aion_store_haematite::HaematiteStore>>,
1734 shard_directory: Option<&Arc<crate::routing::StaticShardDirectory>>,
1735 request_forwarder: Option<&Arc<dyn crate::routing::RequestForwarder>>,
1736) -> Option<crate::namespace::NamespaceRouting> {
1737 use crate::namespace::{
1738 GrpcMintForwarder, MintForwarder, MintShardOwners, NamespaceRouting, NamespaceShardResolver,
1739 };
1740 let store = Arc::clone(cluster_store?);
1741 let directory = Arc::clone(shard_directory?);
1742 let shards: Arc<dyn NamespaceShardResolver> = store;
1743 let owners: Arc<dyn MintShardOwners> = directory;
1744 let forwarder: Arc<dyn MintForwarder> =
1745 Arc::new(GrpcMintForwarder::new(Arc::clone(request_forwarder?)));
1746 Some(NamespaceRouting::new(shards, owners, forwarder))
1747}
1748
1749struct ConnectedStore {
1759 event_store: Arc<dyn EventStore>,
1760 outbox_store: Option<Arc<dyn OutboxStore>>,
1761 namespace_store: Arc<dyn NamespaceStore>,
1768 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
1770 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1778 bootstrap_coordinator: bool,
1779 #[cfg(feature = "haematite-backend")]
1780 cluster_responder: Option<aion_store_haematite::ClusterResponder>,
1781 #[cfg(feature = "haematite-backend")]
1785 cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
1786 #[cfg(feature = "haematite-backend")]
1789 watched_peers: Vec<crate::cluster::WatchedPeer>,
1790 #[cfg(feature = "haematite-backend")]
1794 directory_peers: Vec<crate::routing::DirectoryPeer>,
1795 #[cfg(feature = "haematite-backend")]
1799 self_node_id: Option<String>,
1800}
1801
1802impl ConnectedStore {
1803 fn local(
1810 event_store: Arc<dyn EventStore>,
1811 outbox_store: Option<Arc<dyn OutboxStore>>,
1812 namespace_store: Arc<dyn NamespaceStore>,
1813 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
1814 ) -> Self {
1815 Self {
1816 event_store,
1817 outbox_store,
1818 namespace_store,
1819 worker_deployment_store,
1820 observability_store: None,
1824 bootstrap_coordinator: true,
1825 #[cfg(feature = "haematite-backend")]
1826 cluster_responder: None,
1827 #[cfg(feature = "haematite-backend")]
1828 cluster_store: None,
1829 #[cfg(feature = "haematite-backend")]
1830 watched_peers: Vec::new(),
1831 #[cfg(feature = "haematite-backend")]
1832 directory_peers: Vec::new(),
1833 #[cfg(feature = "haematite-backend")]
1834 self_node_id: None,
1835 }
1836 }
1837
1838 #[cfg(feature = "haematite-backend")]
1841 fn cluster_self_node(&self) -> Option<String> {
1842 self.self_node_id.clone()
1843 }
1844
1845 #[cfg(not(feature = "haematite-backend"))]
1847 const fn cluster_self_node(&self) -> Option<String> {
1848 None
1849 }
1850}
1851
1852async fn connect_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
1862 match config.backend {
1863 StoreBackend::Memory => {
1864 let leaf = Arc::new(aion_store::InMemoryStore::default());
1867 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
1868 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
1869 Ok(ConnectedStore::local(
1870 leaf,
1871 None,
1872 namespace_store,
1873 worker_deployment_store,
1874 ))
1875 }
1876 StoreBackend::LibSql => {
1877 #[cfg(feature = "libsql-backend")]
1878 {
1879 connect_libsql_store(config).await
1880 }
1881 #[cfg(not(feature = "libsql-backend"))]
1882 {
1883 let _ = config;
1884 connect_libsql_store_unavailable()
1885 }
1886 }
1887 StoreBackend::Haematite => {
1888 #[cfg(feature = "haematite-backend")]
1889 {
1890 connect_haematite_store(config).await
1891 }
1892 #[cfg(not(feature = "haematite-backend"))]
1893 {
1894 let _ = config;
1895 connect_haematite_store_unavailable()
1896 }
1897 }
1898 }
1899}
1900
1901#[cfg(feature = "libsql-backend")]
1905async fn connect_libsql_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
1906 let Some(url) = config.url else {
1907 return Err(ServerError::Config {
1908 message: "store.url must not be empty when store.backend is libsql".to_owned(),
1909 });
1910 };
1911 let store = LibSqlStore::open(url.clone())
1912 .await
1913 .map_err(ServerError::from)?;
1914 store
1915 .validate_event_compatibility()
1916 .await
1917 .map_err(|error| match error {
1918 aion_store::StoreError::Serialization(_) => ServerError::Config {
1919 message: format!(
1920 "Database schema mismatch — delete {url} and restart, or run migrations."
1921 ),
1922 },
1923 other => ServerError::from(other),
1924 })?;
1925 let leaf = Arc::new(store);
1926 let event_store: Arc<dyn EventStore> = leaf.clone();
1927 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
1928 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
1929 let outbox_store: Arc<dyn OutboxStore> = leaf;
1930 Ok(ConnectedStore::local(
1931 event_store,
1932 Some(outbox_store),
1933 namespace_store,
1934 worker_deployment_store,
1935 ))
1936}
1937
1938#[cfg(not(feature = "libsql-backend"))]
1942fn connect_libsql_store_unavailable() -> Result<ConnectedStore, ServerError> {
1943 Err(ServerError::Config {
1944 message: "store.backend = libsql requires the aion-server `libsql-backend` feature"
1945 .to_owned(),
1946 })
1947}
1948
1949#[cfg(feature = "haematite-backend")]
1969async fn connect_haematite_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
1970 let Some(data_dir) = config.data_dir else {
1971 return Err(ServerError::Config {
1972 message: "store.data_dir must not be empty when store.backend is haematite".to_owned(),
1973 });
1974 };
1975 let shard_count = config.shard_count;
1976 let owned_shards = config.owned_shards.clone();
1977 let cluster = config.cluster.clone();
1978 let watched_peers: Vec<crate::cluster::WatchedPeer> = cluster
1983 .as_ref()
1984 .map(|cluster| {
1985 cluster
1986 .peers
1987 .iter()
1988 .map(|peer| crate::cluster::WatchedPeer {
1989 name: peer.name.clone(),
1990 owned_shards: peer.owned_shards.clone(),
1991 })
1992 .collect()
1993 })
1994 .unwrap_or_default();
1995 let directory_peers: Vec<crate::routing::DirectoryPeer> = cluster
1998 .as_ref()
1999 .map(|cluster| {
2000 cluster
2001 .peers
2002 .iter()
2003 .map(|peer| crate::routing::DirectoryPeer {
2004 name: peer.name.clone(),
2005 owned_shards: peer.owned_shards.clone(),
2006 grpc_addr: peer.grpc_address,
2007 })
2008 .collect()
2009 })
2010 .unwrap_or_default();
2011 let self_node_id: Option<String> = cluster.as_ref().map(|cluster| cluster.node_id.clone());
2014 let (store, responder) =
2018 tokio::task::spawn_blocking(move || build_haematite_store(&data_dir, shard_count, cluster))
2019 .await
2020 .map_err(|error| ServerError::Config {
2021 message: format!("haematite store initialization task failed: {error}"),
2022 })??;
2023
2024 let bootstrap_coordinator = if owned_shards.is_empty() {
2028 true
2029 } else {
2030 store.set_owned_shards(owned_shards.iter().copied());
2031 store.owns_workflow_shard(&aion::schedule_coordinator_workflow_id())
2032 };
2033
2034 let leaf = Arc::new(store);
2035 let event_store: Arc<dyn EventStore> = leaf.clone();
2036 let outbox_store: Arc<dyn OutboxStore> = leaf.clone();
2037 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
2041 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
2042 let observability_store: Arc<dyn aion_store::ObservabilityStore> = leaf.clone();
2047 let cluster_store = responder.as_ref().map(|_| leaf);
2051 let (watched_peers, directory_peers, self_node_id) = if cluster_store.is_some() {
2052 (watched_peers, directory_peers, self_node_id)
2053 } else {
2054 (Vec::new(), Vec::new(), None)
2055 };
2056 Ok(ConnectedStore {
2057 event_store,
2058 outbox_store: Some(outbox_store),
2059 namespace_store,
2060 worker_deployment_store,
2061 observability_store: Some(observability_store),
2062 bootstrap_coordinator,
2063 cluster_responder: responder,
2064 cluster_store,
2065 watched_peers,
2066 directory_peers,
2067 self_node_id,
2068 })
2069}
2070
2071#[cfg(feature = "haematite-backend")]
2086fn build_haematite_store(
2087 data_dir: &str,
2088 shard_count: usize,
2089 cluster: Option<crate::config::ClusterConfig>,
2090) -> Result<
2091 (
2092 aion_store_haematite::HaematiteStore,
2093 Option<aion_store_haematite::ClusterResponder>,
2094 ),
2095 ServerError,
2096> {
2097 build_haematite_store_with_hook(data_dir, shard_count, cluster, || Ok(()))
2098}
2099
2100#[cfg(feature = "haematite-backend")]
2101fn build_haematite_store_with_hook(
2102 data_dir: &str,
2103 shard_count: usize,
2104 cluster: Option<crate::config::ClusterConfig>,
2105 before_backend_touch: impl FnOnce() -> Result<(), std::io::Error>,
2106) -> Result<
2107 (
2108 aion_store_haematite::HaematiteStore,
2109 Option<aion_store_haematite::ClusterResponder>,
2110 ),
2111 ServerError,
2112> {
2113 use aion_store_haematite::{ClusterBootstrap, HaematiteStore};
2114
2115 let private_root = crate::filesystem::ConfinedDir::open_or_create(std::path::Path::new(
2123 data_dir,
2124 ))
2125 .map_err(|error| ServerError::Config {
2126 message: format!("unsafe store.data_dir `{data_dir}`: {error}"),
2127 })?;
2128
2129 for shard in 0..shard_count {
2133 private_root
2134 .create_dir_all(std::path::Path::new(&format!("shard-{shard}")))
2135 .map_err(|error| ServerError::Config {
2136 message: format!(
2137 "failed to materialize shard-{shard} under store.data_dir `{data_dir}`: {error}"
2138 ),
2139 })?;
2140 }
2141 private_root
2142 .harden_tree()
2143 .map_err(|error| private_store_mode_error(data_dir, &error))?;
2144
2145 before_backend_touch().map_err(|error| ServerError::Config {
2148 message: format!("store.data_dir pre-open hook failed: {error}"),
2149 })?;
2150
2151 #[cfg(unix)]
2152 let backend_path = private_root
2153 .backend_path()
2154 .map_err(|error| ServerError::Config {
2155 message: format!("failed to resolve held store.data_dir `{data_dir}`: {error}"),
2156 })?;
2157 #[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
2158 crate::filesystem::validate_ambient_backend_ancestors(&backend_path).map_err(|error| {
2159 let (component, reason) = error.into_parts();
2160 ServerError::UnsafeDataRootAncestor {
2161 data_root: backend_path.clone(),
2162 component,
2163 reason,
2164 }
2165 })?;
2166 #[cfg(not(unix))]
2167 let backend_path = std::path::PathBuf::from(data_dir);
2168
2169 let Some(cluster) = cluster else {
2170 let store = if backend_path.join("config.json").exists() {
2171 HaematiteStore::open(&backend_path).map_err(ServerError::from)?
2172 } else {
2173 HaematiteStore::create_with_shard_count(&backend_path, shard_count)
2174 .map_err(ServerError::from)?
2175 };
2176 store.materialize_all_shards().map_err(ServerError::from)?;
2177 private_root
2178 .harden_tree()
2179 .map_err(|error| private_store_mode_error(data_dir, &error))?;
2180 let store = store.retain_data_root_capability(private_root);
2181 return Ok((store, None));
2182 };
2183
2184 let boot = ClusterBootstrap {
2185 node_id: cluster.node_id,
2186 bind_address: cluster.bind_address,
2187 members: cluster.members,
2188 peers: cluster
2189 .peers
2190 .into_iter()
2191 .map(|peer| (peer.name, peer.address))
2192 .collect(),
2193 timeout: HAEMATITE_CLUSTER_OP_TIMEOUT,
2194 };
2195 let (store, responder) =
2196 HaematiteStore::open_or_create_distributed(&backend_path, shard_count, boot)
2197 .map_err(ServerError::from)?;
2198 store.materialize_all_shards().map_err(ServerError::from)?;
2199 private_root
2200 .harden_tree()
2201 .map_err(|error| private_store_mode_error(data_dir, &error))?;
2202 let store = store.retain_data_root_capability(private_root);
2203 Ok((store, Some(responder)))
2204}
2205
2206#[cfg(feature = "haematite-backend")]
2207fn private_store_mode_error(data_dir: &str, error: &std::io::Error) -> ServerError {
2208 ServerError::Config {
2209 message: format!(
2210 "failed to apply private modes under store.data_dir `{data_dir}`: {error}"
2211 ),
2212 }
2213}
2214
2215#[cfg(feature = "haematite-backend")]
2217const HAEMATITE_CLUSTER_OP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
2218
2219#[cfg(not(feature = "haematite-backend"))]
2223fn connect_haematite_store_unavailable() -> Result<ConnectedStore, ServerError> {
2224 Err(ServerError::Config {
2225 message: "store.backend = haematite requires the aion-server `haematite-backend` feature"
2226 .to_owned(),
2227 })
2228}
2229
2230#[cfg(not(feature = "liminal-transport"))]
2239#[derive(Clone, Debug)]
2240struct NullInterventionTransport;
2241
2242#[cfg(not(feature = "liminal-transport"))]
2243#[async_trait::async_trait]
2244impl crate::worker::InterventionTransport for NullInterventionTransport {
2245 async fn push(
2246 &self,
2247 _worker: &crate::worker::WorkerHandle,
2248 _command: aion_core::InterventionCommand,
2249 ) -> Result<aion_core::InterventionOutcome, ServerError> {
2250 Err(ServerError::worker_connection_lost(
2251 "intervention",
2252 "no intervention push transport is compiled in".to_owned(),
2253 ))
2254 }
2255}
2256
2257#[cfg(test)]
2258mod tests {
2259 use std::{net::SocketAddr, time::Duration};
2260
2261 use aion_store::InMemoryStore;
2262
2263 use super::ServerState;
2264 use crate::config::{
2265 AuthConfig, AuthoringConfig, DeployConfig, DevConfig, ListenConfig, MetricsConfig,
2266 NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig, OutboxConfig,
2267 RuntimeConfig, WebSocketConfig, WorkerConfig,
2268 };
2269
2270 fn runtime_config() -> RuntimeConfig {
2271 RuntimeConfig {
2272 listen: ListenConfig {
2273 grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
2274 http: SocketAddr::from(([127, 0, 0, 1], 8080)),
2275 },
2276 tls: None,
2277 auth: AuthConfig {
2278 enabled: false,
2279 jwks_url: None,
2280 jwks_refresh_seconds: 300,
2281 },
2282 ops_console: OpsConsoleConfig {
2283 source: OpsConsoleAssetSource::Embedded,
2284 },
2285 namespace: NamespaceConfig {
2286 mode: NamespaceMode::SharedEngine,
2287 },
2288 worker: WorkerConfig {
2289 heartbeat_window: Duration::from_secs(30),
2290 ..WorkerConfig::default()
2291 },
2292 websocket: WebSocketConfig {
2293 outbound_buffer_bound: 32,
2294 event_broadcast_capacity: Some(64),
2295 cluster_broadcast_capacity: Some(64),
2296 },
2297 workflow_packages: Vec::new(),
2298 deploy: DeployConfig::default(),
2299 authoring: AuthoringConfig::default(),
2300 dev: DevConfig::default(),
2301 outbox: OutboxConfig::default(),
2302 observability: crate::config::ObservabilityConfig::default(),
2303 mcp: crate::config::ResolvedMcpConfig::default(),
2304 scheduler_threads: 1,
2305 query_timeout: Some(Duration::from_secs(10)),
2306 default_namespace: "default".to_owned(),
2307 auto_create: crate::config::AutoCreate::Open,
2308 max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
2309 drain_timeout: Duration::from_secs(30),
2310 metrics: MetricsConfig { enabled: true },
2311 owned_shards: Vec::new(),
2312 cors_allowed_origins: Vec::new(),
2313 }
2314 }
2315
2316 #[tokio::test]
2317 async fn builds_state_with_in_memory_store() -> Result<(), Box<dyn std::error::Error>> {
2318 let state =
2319 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2320
2321 std::hint::black_box(state.namespace_guard());
2322 std::hint::black_box(state.worker_registry());
2323
2324 Ok(())
2325 }
2326
2327 #[tokio::test]
2335 async fn unserved_queues_surfaces_a_parked_dispatch_and_clears_it()
2336 -> Result<(), Box<dyn std::error::Error>> {
2337 use aion::{ActivityDispatch, ActivityDispatcher as _};
2338 use aion_core::{ActivityId, RunId, WorkflowId};
2339 use std::sync::Arc;
2340
2341 let state =
2342 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2343 assert!(
2344 state.unserved_queues()?.is_empty(),
2345 "a calm boot has no unserved queues"
2346 );
2347 assert!(state.queue_declarations().is_installed());
2351 assert_eq!(
2352 state
2353 .queue_declarations()
2354 .declaration_for("nobody-serves-this"),
2355 crate::worker::QueueDeclaration::Unknown
2356 );
2357
2358 let dispatcher = Arc::new(
2359 crate::worker::WorkerActivityDispatcher::new(
2360 state.worker_registry().clone(),
2361 "default",
2362 crate::worker::HeartbeatTracker::new(Duration::from_secs(5)),
2363 )
2364 .with_queue_state(state.queue_service_state().clone())
2365 .with_queue_declarations(state.queue_declarations().clone()),
2366 );
2367 let workflow_id = WorkflowId::new_v4();
2368 let request = ActivityDispatch {
2369 namespace: "default".to_owned(),
2370 task_queue: "nobody-serves-this".to_owned(),
2371 node: None,
2372 workflow_id: workflow_id.clone(),
2373 run_id: RunId::new_v4(),
2374 activity_id: ActivityId::from_sequence_position(0),
2375 name: "greet".to_owned(),
2376 input: "{}".to_owned(),
2377 config: "{}".to_owned(),
2378 attempt: 1,
2379 advisory: false,
2380 labels: std::collections::BTreeMap::new(),
2381 };
2382 let parked = std::thread::spawn(move || dispatcher.dispatch(request));
2383
2384 let mut unserved = Vec::new();
2385 for _ in 0..30 {
2386 unserved = state.unserved_queues()?;
2387 if !unserved.is_empty() {
2388 break;
2389 }
2390 tokio::time::sleep(Duration::from_millis(100)).await;
2391 }
2392 assert_eq!(unserved.len(), 1, "the parked dispatch is not surfaced");
2393 assert_eq!(
2394 unserved[0].reason,
2395 crate::worker::QueueServiceReason::NoLivePollers,
2396 "an empty catalog must not be read as a structural refusal"
2397 );
2398 assert_eq!(unserved[0].key.task_queue, "nobody-serves-this");
2399 assert_eq!(unserved[0].waiting.len(), 1);
2400 assert_eq!(unserved[0].waiting[0].workflow_id, workflow_id);
2401
2402 let (worker_tx, worker_rx) = tokio::sync::mpsc::channel(1);
2404 drop(worker_rx);
2405 let registration = state.worker_registry().register_namespaces(
2406 [String::from("default")],
2407 "nobody-serves-this",
2408 None,
2409 [String::from("greet")].iter(),
2410 worker_tx,
2411 )?;
2412 let outcome = parked.join().map_err(|_| "parked dispatch panicked")?;
2413 assert!(outcome.is_err(), "the released dispatch must resolve");
2414 assert!(
2415 state.unserved_queues()?.is_empty(),
2416 "a resolved dispatch must leave the unserved state"
2417 );
2418 registration.deregister()?;
2419 Ok(())
2420 }
2421
2422 #[tokio::test]
2423 async fn namespace_store_is_reachable_and_functional_after_default_boot()
2424 -> Result<(), Box<dyn std::error::Error>> {
2425 use aion_store::{MintOutcome, NamespaceOrigin};
2426
2427 let state =
2431 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2432
2433 let store = state.namespace_store();
2434
2435 let outcome = store
2437 .register_namespace("orders", NamespaceOrigin::WorkerMint)
2438 .await?;
2439 assert_eq!(
2440 outcome,
2441 MintOutcome::Created,
2442 "the first reference to a namespace mints it"
2443 );
2444
2445 let again = store
2447 .register_namespace("orders", NamespaceOrigin::WorkerMint)
2448 .await?;
2449 assert_eq!(
2450 again,
2451 MintOutcome::AlreadyExisted,
2452 "a second reference touches the existing record rather than re-creating it"
2453 );
2454
2455 let fetched = store.get_namespace("orders").await?;
2457 let record = fetched.ok_or("registered namespace must be retrievable via get_namespace")?;
2458 assert_eq!(record.name, "orders");
2459 assert_eq!(record.origin, NamespaceOrigin::WorkerMint);
2460
2461 let listed = store.list_namespaces().await?;
2463 assert!(
2464 listed.iter().any(|record| record.name == "orders"),
2465 "list_namespaces returns the minted namespace"
2466 );
2467
2468 Ok(())
2469 }
2470
2471 #[cfg(feature = "haematite-backend")]
2472 #[tokio::test(flavor = "multi_thread")]
2473 async fn connect_store_haematite_round_trips_through_event_store()
2474 -> Result<(), Box<dyn std::error::Error>> {
2475 use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
2476 use aion_store::WriteToken;
2477 use chrono::Utc;
2478
2479 use crate::config::{StoreBackend, StoreConfig};
2480
2481 let data_dir = crate::test_support::private_tempdir()?;
2482 let connected = super::connect_store(StoreConfig {
2486 backend: StoreBackend::Haematite,
2487 url: None,
2488 owned_shards: Vec::new(),
2489 data_dir: Some(data_dir.path().to_string_lossy().into_owned()),
2490 shard_count: 1,
2491 cluster: None,
2492 })
2493 .await?;
2494 let event_store = connected.event_store;
2495 assert!(
2496 connected.outbox_store.is_some(),
2497 "the haematite backend shares its leaf store as the dispatcher's outbox store"
2498 );
2499 assert!(
2500 connected.bootstrap_coordinator,
2501 "a single-node haematite boot owns all shards and bootstraps the coordinator"
2502 );
2503 assert!(
2504 connected.cluster_responder.is_none(),
2505 "a single-node (no [cluster]) haematite boot has no distributed responder"
2506 );
2507
2508 let workflow_id = WorkflowId::new_v4();
2509 let event = aion_core::Event::WorkflowStarted {
2510 envelope: EventEnvelope {
2511 seq: 1,
2512 recorded_at: Utc::now(),
2513 workflow_id: workflow_id.clone(),
2514 },
2515 workflow_type: String::from("checkout"),
2516 input: Payload::new(ContentType::Json, b"{}".to_vec()),
2517 run_id: RunId::new_v4(),
2518 parent_run_id: None,
2519 package_version: PackageVersion::new("a".repeat(64)),
2520 };
2521 event_store
2522 .append(
2523 WriteToken::recorder(),
2524 &workflow_id,
2525 std::slice::from_ref(&event),
2526 0,
2527 )
2528 .await?;
2529 let history = event_store.read_history(&workflow_id).await?;
2530 assert_eq!(
2531 history.len(),
2532 1,
2533 "an event appended through the server's dyn EventStore reads back"
2534 );
2535 Ok(())
2536 }
2537
2538 #[cfg(all(feature = "haematite-backend", unix))]
2539 #[test]
2540 fn haematite_root_swap_before_first_backend_touch_cannot_redirect_writes()
2541 -> Result<(), Box<dyn std::error::Error>> {
2542 use std::os::unix::fs::symlink;
2543
2544 let sandbox = crate::test_support::private_tempdir()?;
2545 let configured_root = sandbox.path().join("data");
2546 let held_root = sandbox.path().join("held-data");
2547 let outside = sandbox.path().join("outside");
2548 std::fs::create_dir(&outside)?;
2549 let configured = configured_root
2550 .to_str()
2551 .ok_or("temporary data path was not UTF-8")?;
2552
2553 let (store, responder) =
2554 super::build_haematite_store_with_hook(configured, 4, None, || {
2555 std::fs::rename(&configured_root, &held_root)?;
2560 symlink(&outside, &configured_root)?;
2561 Ok(())
2562 })?;
2563 assert!(responder.is_none());
2564
2565 let outside_entries = std::fs::read_dir(&outside)?.collect::<Result<Vec<_>, _>>()?;
2566 assert!(
2567 outside_entries.is_empty(),
2568 "Haematite followed the replaced ambient root and wrote outside"
2569 );
2570 assert!(held_root.join("config.json").is_file());
2571 for shard in 0..4 {
2572 let shard_path = held_root.join(format!("shard-{shard}"));
2573 assert!(shard_path.is_dir(), "shard {shard} was not materialized");
2574 assert!(
2575 std::fs::read_dir(&shard_path)?
2576 .next()
2577 .transpose()?
2578 .is_some(),
2579 "shard {shard} did not run Haematite's materialization path"
2580 );
2581 }
2582
2583 drop(store);
2584 Ok(())
2585 }
2586
2587 #[cfg(all(
2588 feature = "haematite-backend",
2589 any(target_os = "linux", target_os = "android")
2590 ))]
2591 #[tokio::test]
2592 async fn proc_fd_backend_path_survives_a_post_startup_root_swap()
2593 -> Result<(), Box<dyn std::error::Error>> {
2594 use std::os::unix::fs::symlink;
2595
2596 use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
2597 use aion_store::{WritableEventStore as _, WriteToken};
2598 use chrono::Utc;
2599
2600 let sandbox = crate::test_support::private_tempdir()?;
2601 let configured_root = sandbox.path().join("data");
2602 let held_root = sandbox.path().join("held-data");
2603 let capture = sandbox.path().join("capture");
2604 std::fs::create_dir(&capture)?;
2605 let configured = configured_root
2606 .to_str()
2607 .ok_or("temporary data path was not UTF-8")?;
2608
2609 let (store, responder) = super::build_haematite_store(configured, 4, None)?;
2610 assert!(responder.is_none());
2611 std::fs::rename(&configured_root, &held_root)?;
2612 symlink(&capture, &configured_root)?;
2613
2614 let workflow_id = WorkflowId::new_v4();
2615 let event = aion_core::Event::WorkflowStarted {
2616 envelope: EventEnvelope {
2617 seq: 1,
2618 recorded_at: Utc::now(),
2619 workflow_id: workflow_id.clone(),
2620 },
2621 workflow_type: String::from("post-startup-root-swap"),
2622 input: Payload::new(ContentType::Json, b"{}".to_vec()),
2623 run_id: RunId::new_v4(),
2624 parent_run_id: None,
2625 package_version: PackageVersion::new("a".repeat(64)),
2626 };
2627 store
2628 .append(
2629 WriteToken::recorder(),
2630 &workflow_id,
2631 std::slice::from_ref(&event),
2632 0,
2633 )
2634 .await?;
2635
2636 let captured = std::fs::read_dir(&capture)?.collect::<Result<Vec<_>, _>>()?;
2637 assert!(
2638 captured.is_empty(),
2639 "post-startup append followed the replacement symlink into capture"
2640 );
2641 assert!(held_root.join("config.json").is_file());
2642 drop(store);
2643 Ok(())
2644 }
2645
2646 #[cfg(all(
2647 feature = "haematite-backend",
2648 unix,
2649 not(any(target_os = "linux", target_os = "android"))
2650 ))]
2651 #[test]
2652 fn path_ambient_haematite_refuses_group_or_world_writable_ancestors()
2653 -> Result<(), Box<dyn std::error::Error>> {
2654 use std::os::unix::fs::PermissionsExt as _;
2655
2656 let sandbox = crate::test_support::private_tempdir()?;
2657 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2658
2659 for mode in [0o770, 0o1777] {
2660 let shared = sandbox.path().join(format!("shared-{mode:o}"));
2661 let data_root = shared.join("data");
2662 std::fs::create_dir(&shared)?;
2663 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(mode))?;
2664 std::fs::create_dir(&data_root)?;
2665 std::fs::set_permissions(&data_root, std::fs::Permissions::from_mode(0o700))?;
2666 let configured = data_root
2667 .to_str()
2668 .ok_or("temporary data path was not UTF-8")?;
2669
2670 let Err(error) = super::build_haematite_store(configured, 4, None) else {
2671 return Err(format!("mode {mode:04o} ancestor was accepted").into());
2672 };
2673 let message = error.to_string();
2674 let crate::ServerError::UnsafeDataRootAncestor {
2675 data_root: resolved_root,
2676 component,
2677 reason,
2678 } = error
2679 else {
2680 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
2681 };
2682 assert_eq!(resolved_root, std::fs::canonicalize(&data_root)?);
2683 assert_eq!(component, std::fs::canonicalize(&shared)?);
2684 assert!(
2685 reason.contains(&format!("mode {mode:04o}")),
2686 "unexpected reason: {reason}"
2687 );
2688 if mode & 0o1000 != 0 {
2689 assert!(reason.contains("sticky bit is not accepted"));
2690 }
2691 assert!(message.contains("private Aion home"));
2692 assert!(
2693 !data_root.join("config.json").exists(),
2694 "Haematite touched its ambient path before the refusal"
2695 );
2696 }
2697 Ok(())
2698 }
2699
2700 #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2701 #[test]
2702 fn path_ambient_haematite_refuses_mutating_allow_acl_ancestor()
2703 -> Result<(), Box<dyn std::error::Error>> {
2704 use std::os::unix::fs::PermissionsExt as _;
2705
2706 let sandbox = crate::test_support::private_tempdir()?;
2707 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2708 let shared = sandbox.path().join("acl-shared");
2709 let data_root = shared.join("data");
2710 std::fs::create_dir(&shared)?;
2711 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o700))?;
2712 let acl = "everyone allow list,search,add_file,add_subdirectory,delete_child";
2713 let status = std::process::Command::new("chmod")
2714 .arg("+a")
2715 .arg(acl)
2716 .arg(&shared)
2717 .status()?;
2718 assert!(status.success(), "failed to install Darwin regression ACL");
2719 let configured = data_root
2720 .to_str()
2721 .ok_or("temporary data path was not UTF-8")?;
2722
2723 let result = super::build_haematite_store(configured, 4, None);
2724 let cleanup = std::process::Command::new("chmod")
2725 .arg("-RN")
2726 .arg(&shared)
2727 .status()?;
2728 assert!(cleanup.success(), "failed to clean Darwin regression ACL");
2729
2730 let Err(error) = result else {
2731 return Err("mutating non-euid allow ACL ancestor was accepted".into());
2732 };
2733 let message = error.to_string();
2734 let crate::ServerError::UnsafeDataRootAncestor {
2735 component, reason, ..
2736 } = error
2737 else {
2738 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
2739 };
2740 assert_eq!(component, std::fs::canonicalize(&shared)?);
2741 assert!(
2742 reason.contains("allow"),
2743 "reason did not name the ACE: {reason}"
2744 );
2745 assert!(
2746 reason.contains("everyone"),
2747 "reason did not name the ACE principal: {reason}"
2748 );
2749 assert!(
2750 !data_root.join("config.json").exists(),
2751 "Haematite touched its ambient path before the ACL refusal"
2752 );
2753 Ok(())
2754 }
2755
2756 #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2757 #[test]
2758 fn path_ambient_haematite_accepts_the_euid_uuid_allow_ace()
2759 -> Result<(), Box<dyn std::error::Error>> {
2760 use std::os::unix::fs::PermissionsExt as _;
2761
2762 use exacl::{AclEntry, AclOption, Perm};
2763
2764 let sandbox = crate::test_support::private_tempdir()?;
2765 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2766 let private_parent = sandbox.path().join("euid-uuid-allow");
2767 let data_root = private_parent.join("data");
2768 std::fs::create_dir(&private_parent)?;
2769 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
2770
2771 let server_uid = rustix::process::geteuid().as_raw();
2772 let ace_qualifier = crate::filesystem::darwin_user_uuid_for_test(server_uid)?;
2773 let entry = AclEntry::allow_user(
2774 &ace_qualifier.to_string(),
2775 Perm::EXECUTE | Perm::WRITE | Perm::APPEND | Perm::DELETE_CHILD,
2776 None,
2777 );
2778 exacl::setfacl(
2779 &[private_parent.as_path()],
2780 &[entry],
2781 AclOption::SYMLINK_ACL,
2782 )?;
2783 let configured = data_root
2784 .to_str()
2785 .ok_or("temporary data path was not UTF-8")?;
2786
2787 let result = super::build_haematite_store(configured, 4, None);
2788 let cleanup = std::process::Command::new("chmod")
2789 .arg("-RN")
2790 .arg(&private_parent)
2791 .status()?;
2792 assert!(cleanup.success(), "failed to clean euid UUID allow ACL");
2793
2794 let (store, responder) = result?;
2795 assert!(responder.is_none());
2796 assert!(data_root.join("config.json").is_file());
2797 drop(store);
2798 Ok(())
2799 }
2800
2801 #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2802 #[test]
2803 fn path_ambient_haematite_refuses_a_non_euid_user_uuid_allow_ace()
2804 -> Result<(), Box<dyn std::error::Error>> {
2805 use std::os::unix::fs::PermissionsExt as _;
2806
2807 use exacl::{AclEntry, AclOption, Perm};
2808
2809 let sandbox = crate::test_support::private_tempdir()?;
2810 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2811 let shared = sandbox.path().join("non-euid-uuid-allow");
2812 let data_root = shared.join("data");
2813 std::fs::create_dir(&shared)?;
2814 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o700))?;
2815
2816 let server_uid = rustix::process::geteuid().as_raw();
2817 let foreign_uid = u32::from(server_uid == 0);
2818 let foreign_qualifier = crate::filesystem::darwin_user_uuid_for_test(foreign_uid)?;
2819 let entry = AclEntry::allow_user(
2820 &foreign_qualifier.to_string(),
2821 Perm::EXECUTE | Perm::WRITE | Perm::APPEND | Perm::DELETE_CHILD,
2822 None,
2823 );
2824 exacl::setfacl(&[shared.as_path()], &[entry], AclOption::SYMLINK_ACL)?;
2825 let configured = data_root
2826 .to_str()
2827 .ok_or("temporary data path was not UTF-8")?;
2828
2829 let result = super::build_haematite_store(configured, 4, None);
2830 let cleanup = std::process::Command::new("chmod")
2831 .arg("-RN")
2832 .arg(&shared)
2833 .status()?;
2834 assert!(cleanup.success(), "failed to clean non-euid UUID allow ACL");
2835
2836 let Err(error) = result else {
2837 return Err("mutating non-euid user UUID allow ACE was accepted".into());
2838 };
2839 let message = error.to_string();
2840 let crate::ServerError::UnsafeDataRootAncestor {
2841 component, reason, ..
2842 } = error
2843 else {
2844 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
2845 };
2846 assert_eq!(component, std::fs::canonicalize(&shared)?);
2847 assert!(
2848 reason.contains("allow") && reason.contains(&format!("server euid {server_uid}")),
2849 "reason did not name the rejected ACE: {reason}"
2850 );
2851 assert!(
2852 !data_root.join("config.json").exists(),
2853 "Haematite touched its ambient path before the UUID ACL refusal"
2854 );
2855 Ok(())
2856 }
2857
2858 #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2859 #[test]
2860 fn path_ambient_haematite_accepts_a_deny_only_acl_ancestor()
2861 -> Result<(), Box<dyn std::error::Error>> {
2862 use std::os::unix::fs::PermissionsExt as _;
2863
2864 let sandbox = crate::test_support::private_tempdir()?;
2865 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2866 let private_parent = sandbox.path().join("deny-only");
2867 let data_root = private_parent.join("data");
2868 std::fs::create_dir(&private_parent)?;
2869 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
2870 let status = std::process::Command::new("chmod")
2871 .arg("+a")
2872 .arg("everyone deny delete")
2873 .arg(&private_parent)
2874 .status()?;
2875 assert!(status.success(), "failed to install Darwin deny-only ACL");
2876 let configured = data_root
2877 .to_str()
2878 .ok_or("temporary data path was not UTF-8")?;
2879
2880 let result = super::build_haematite_store(configured, 4, None);
2881 let cleanup = std::process::Command::new("chmod")
2882 .arg("-RN")
2883 .arg(&private_parent)
2884 .status()?;
2885 assert!(cleanup.success(), "failed to clean Darwin deny-only ACL");
2886
2887 let (store, responder) = result?;
2888 assert!(responder.is_none());
2889 assert!(data_root.join("config.json").is_file());
2890 drop(store);
2891 Ok(())
2892 }
2893
2894 #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2895 #[test]
2896 fn path_ambient_haematite_accepts_the_stock_home_acl_chain()
2897 -> Result<(), Box<dyn std::error::Error>> {
2898 use std::os::unix::fs::PermissionsExt as _;
2899 use users::os::unix::UserExt as _;
2900
2901 let effective_uid = rustix::process::geteuid().as_raw();
2902 let effective_user = users::get_user_by_uid(effective_uid)
2903 .ok_or_else(|| format!("server euid {effective_uid} has no account record"))?;
2904 let sandbox = tempfile::Builder::new()
2905 .prefix(".aion-acl-home-proof-")
2906 .tempdir_in(effective_user.home_dir())?;
2907 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2908 let data_root = sandbox.path().join("data");
2909 let configured = data_root
2910 .to_str()
2911 .ok_or("temporary data path was not UTF-8")?;
2912
2913 let (store, responder) = super::build_haematite_store(configured, 4, None)?;
2914 assert!(responder.is_none());
2915 assert!(data_root.join("config.json").is_file());
2916 drop(store);
2917 Ok(())
2918 }
2919
2920 #[cfg(all(
2921 feature = "haematite-backend",
2922 unix,
2923 not(any(target_os = "linux", target_os = "android"))
2924 ))]
2925 #[test]
2926 fn path_ambient_haematite_accepts_an_owner_controlled_chain()
2927 -> Result<(), Box<dyn std::error::Error>> {
2928 use std::os::unix::fs::PermissionsExt as _;
2929
2930 let sandbox = crate::test_support::private_tempdir()?;
2931 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2932 let private_parent = sandbox.path().join("private");
2933 let data_root = private_parent.join("data");
2934 std::fs::create_dir(&private_parent)?;
2935 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
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 for shard in 0..4 {
2944 assert!(data_root.join(format!("shard-{shard}")).is_dir());
2945 }
2946 drop(store);
2947 Ok(())
2948 }
2949
2950 #[tokio::test]
2951 async fn connect_store_memory_backend_exposes_no_outbox_store()
2952 -> Result<(), Box<dyn std::error::Error>> {
2953 use crate::config::{StoreBackend, StoreConfig};
2954
2955 let connected = super::connect_store(StoreConfig {
2958 backend: StoreBackend::Memory,
2959 url: None,
2960 owned_shards: Vec::new(),
2961 data_dir: None,
2962 shard_count: 1,
2963 cluster: None,
2964 })
2965 .await?;
2966 assert!(
2967 connected.outbox_store.is_none(),
2968 "the in-memory backend exposes no outbox store"
2969 );
2970 Ok(())
2971 }
2972
2973 #[cfg(feature = "libsql-backend")]
2977 #[tokio::test]
2978 async fn connect_store_shares_outbox_store_only_for_libsql()
2979 -> Result<(), Box<dyn std::error::Error>> {
2980 use crate::config::{StoreBackend, StoreConfig};
2981
2982 let path = std::env::temp_dir().join(format!(
2987 "aion-connect-store-{}-{}.db",
2988 std::process::id(),
2989 std::time::SystemTime::now()
2990 .duration_since(std::time::UNIX_EPOCH)
2991 .map(|elapsed| elapsed.as_nanos())
2992 .unwrap_or_default()
2993 ));
2994 let connected = super::connect_store(StoreConfig {
2995 backend: StoreBackend::LibSql,
2996 url: Some(path.to_string_lossy().into_owned()),
2997 owned_shards: Vec::new(),
2998 data_dir: None,
2999 shard_count: 1,
3000 cluster: None,
3001 })
3002 .await?;
3003 assert!(
3004 connected.outbox_store.is_some(),
3005 "the libSQL backend shares its leaf store as the dispatcher's outbox store"
3006 );
3007 Ok(())
3008 }
3009
3010 #[tokio::test]
3011 async fn state_build_fails_without_event_broadcast_capacity()
3012 -> Result<(), Box<dyn std::error::Error>> {
3013 let mut runtime = runtime_config();
3014 runtime.websocket.event_broadcast_capacity = None;
3015
3016 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
3017 .await
3018 .err()
3019 .ok_or("state build must fail when event streaming is unsized")?;
3020
3021 assert!(error.is_config(), "expected a config error, got {error}");
3022 assert!(
3023 error
3024 .to_string()
3025 .contains("websocket.event_broadcast_capacity"),
3026 "error must name the missing key: {error}"
3027 );
3028 Ok(())
3029 }
3030
3031 #[tokio::test]
3032 async fn state_build_fails_without_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
3033 let mut runtime = runtime_config();
3034 runtime.query_timeout = None;
3035
3036 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
3037 .await
3038 .err()
3039 .ok_or("state build must fail when the query reply deadline is unset")?;
3040
3041 assert!(error.is_config(), "expected a config error, got {error}");
3042 assert!(
3043 error.to_string().contains("runtime.query_timeout_ms"),
3044 "error must name the missing key: {error}"
3045 );
3046 assert!(
3047 error.to_string().contains("AION_RUNTIME_QUERY_TIMEOUT_MS"),
3048 "error must name the environment override: {error}"
3049 );
3050 Ok(())
3051 }
3052
3053 #[tokio::test]
3054 async fn state_build_fails_with_zero_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
3055 let mut runtime = runtime_config();
3056 runtime.query_timeout = Some(Duration::ZERO);
3057
3058 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
3059 .await
3060 .err()
3061 .ok_or("state build must fail when the query reply deadline is zero")?;
3062
3063 assert!(error.is_config(), "expected a config error, got {error}");
3064 assert!(
3065 error.to_string().contains("runtime.query_timeout_ms"),
3066 "error must name the zero-valued key: {error}"
3067 );
3068 Ok(())
3069 }
3070
3071 #[tokio::test(flavor = "multi_thread")]
3094 async fn a_completed_check_through_the_built_dispatcher_lands_in_the_returned_slot()
3095 -> Result<(), Box<dyn std::error::Error>> {
3096 use std::collections::{BTreeMap, VecDeque};
3097 use std::sync::{Arc, Mutex};
3098
3099 use aion::ActivityDispatch;
3100 use aion_core::{ActivityId, RunId, WorkflowId};
3101 use aion_package::ActionBodyContract;
3102
3103 use crate::update_check::document::{FETCH_ACTION, FETCH_COMMAND, UPDATE_CHECK_QUEUE};
3104 use crate::worker::{DeclaredBodies, DeclaredBodyLookup, DispatchingRun};
3105
3106 struct SequencedBodies {
3109 replies: Mutex<VecDeque<DeclaredBodyLookup>>,
3110 }
3111
3112 impl DeclaredBodies for SequencedBodies {
3113 fn body_for(
3114 &self,
3115 _task_queue: &str,
3116 _action: &str,
3117 _run: DispatchingRun<'_>,
3118 ) -> DeclaredBodyLookup {
3119 let mut replies = match self.replies.lock() {
3120 Ok(replies) => replies,
3121 Err(poisoned) => poisoned.into_inner(),
3122 };
3123 replies.pop_front().unwrap_or(DeclaredBodyLookup::None)
3124 }
3125 }
3126
3127 let runtime = runtime_config();
3128 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
3129 ServerState::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
3130 );
3131 let namespace_store: Arc<dyn aion_store::NamespaceStore> =
3132 Arc::new(InMemoryStore::default());
3133 let worker_deployment_store: Arc<dyn aion_store::WorkerDeploymentStore> =
3134 Arc::new(InMemoryStore::default());
3135 let seams = super::build_worker_seams(
3136 &runtime,
3137 &cluster_publisher,
3138 &namespace_store,
3139 &worker_deployment_store,
3140 None,
3141 );
3142
3143 let fixture = concat!(
3146 env!("CARGO_MANIFEST_DIR"),
3147 "/src/update_check/fixtures/aion-cli-index.jsonl"
3148 );
3149 seams.declared_bodies.install(Arc::new(SequencedBodies {
3150 replies: Mutex::new(VecDeque::from([
3151 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
3152 command: FETCH_COMMAND.to_owned(),
3153 }),
3154 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
3155 command: format!("cat {fixture}"),
3156 }),
3157 ])),
3158 }));
3159
3160 let transcript = crate::activity_publisher::ActivityEventPublisher::new(
3161 Arc::new(aion_store::InMemoryObservabilityStore::default()),
3162 ServerState::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
3163 );
3164 let (dispatcher, _mock_registry, _attempt_owners, _workspace_root, update_status) =
3165 super::build_decorated_dispatcher(&runtime, &seams, transcript);
3166
3167 assert_eq!(
3168 update_status.last(),
3169 None,
3170 "the returned slot must start honestly empty"
3171 );
3172
3173 let dispatch = ActivityDispatch {
3174 namespace: "default".to_owned(),
3175 task_queue: UPDATE_CHECK_QUEUE.to_owned(),
3176 node: None,
3177 workflow_id: WorkflowId::new_v4(),
3178 run_id: RunId::new_v4(),
3179 activity_id: ActivityId::from_sequence_position(1),
3180 name: FETCH_ACTION.to_owned(),
3181 input: "{}".to_owned(),
3182 config: "{}".to_owned(),
3183 attempt: 1,
3184 labels: BTreeMap::new(),
3185 advisory: false,
3186 };
3187 let handle = tokio::task::spawn_blocking(move || dispatcher.dispatch(dispatch));
3188 let encoded = handle
3189 .await?
3190 .map_err(|error| format!("the check dispatch failed: {error}"))?;
3191 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
3192 assert_eq!(outcome["exit_code"], 0, "the local stand-in command ran");
3193
3194 let recorded = update_status.last().ok_or(
3195 "the completed check must land in the RETURNED slot — the one the boot path \
3196 stores and /update-status serves; an empty slot here is the disconnected-\
3197 producer mis-wire the r1 review proved unmeasured",
3198 )?;
3199 assert_eq!(recorded.latest_known, "0.13.7");
3200 Ok(())
3201 }
3202}