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
10use crate::dev_ui::{ActivityMockRegistry, DevMockingDispatcher};
11
12#[cfg(feature = "auth")]
13use crate::auth::JwksCache;
14use crate::{
15 config::{RuntimeConfig, ServerConfig, StoreBackend, StoreConfig},
16 error::ServerError,
17 namespace::{NamespaceGuard, NamespaceMinter, resolver::NamespaceResolver},
18 observability::{
19 Metrics, health::HealthState, instrumented_store::InstrumentedEventStore,
20 metrics::MetricsError,
21 },
22 shutdown::DrainState,
23 worker::{
24 ConnectedWorkerRegistry, HeartbeatTracker, PendingActivities, WorkerActivityDispatcher,
25 supervisor::WorkerSupervisor,
26 },
27};
28
29fn new_supervisor(
40 store: &Arc<dyn WorkerDeploymentStore>,
41 publisher: &crate::cluster_publisher::ClusterEventPublisher,
42) -> Arc<WorkerSupervisor> {
43 Arc::new(WorkerSupervisor::new(Arc::clone(store), publisher.clone()))
44}
45
46#[derive(Clone)]
48pub struct ServerState {
49 inner: Arc<ServerStateInner>,
50}
51
52struct ServerStateInner {
53 namespace_guard: NamespaceGuard,
54 runtime: RuntimeConfig,
55 worker_registry: ConnectedWorkerRegistry,
56 pending_activities: PendingActivities,
57 heartbeat_tracker: HeartbeatTracker,
58 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters,
65 drain_state: DrainState,
66 metrics: Option<Metrics>,
67 health: Option<HealthState>,
68 activity_mock_registry: Option<ActivityMockRegistry>,
71 outbox_store: Option<Arc<dyn OutboxStore>>,
74 namespace_store: Arc<dyn NamespaceStore>,
83 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
85 worker_supervisor: Arc<WorkerSupervisor>,
90 outbox_wake: Arc<tokio::sync::Notify>,
97 cluster_publisher: crate::cluster_publisher::ClusterEventPublisher,
101 transcript_publisher: crate::activity_publisher::ActivityEventPublisher,
110 attempt_owners: crate::worker::AttemptOwnerIndex,
117 queue_service_state: crate::worker::QueueServiceState,
123 queue_declarations: crate::worker::QueueDeclarationSource,
127 declared_attempts: crate::worker::DeclaredCommandAttempts,
134 update_status: crate::update_check::UpdateStatusState,
140 workspace_root: crate::worker::WorkspaceRoot,
148 cluster_self_node: Option<String>,
153 cluster_responder: Option<aion_store_haematite::ClusterResponder>,
158 cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
161 watched_peers: Vec<crate::cluster::WatchedPeer>,
164 shard_directory: Option<Arc<crate::routing::StaticShardDirectory>>,
169 request_forwarder: Option<Arc<dyn crate::routing::RequestForwarder>>,
173 #[cfg(feature = "auth")]
174 jwks_cache: Option<JwksCache>,
175}
176
177impl ServerState {
178 const FALLBACK_CLUSTER_BROADCAST_CAPACITY: std::num::NonZeroUsize =
187 match std::num::NonZeroUsize::new(64) {
188 Some(value) => value,
189 None => std::num::NonZeroUsize::MIN,
190 };
191
192 pub async fn build(config: ServerConfig) -> Result<Self, ServerError> {
199 let (store_config, runtime) = config.into_parts();
200 let connected = connect_store(store_config).await?;
201 Self::build_with_connected_store(connected, runtime).await
202 }
203
204 pub async fn build_with_store<S>(store: S, runtime: RuntimeConfig) -> Result<Self, ServerError>
210 where
211 S: EventStore + NamespaceStore + WorkerDeploymentStore,
212 {
213 let leaf = Arc::new(store);
217 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
218 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
219 Self::build_with_connected_store(
220 ConnectedStore::local(leaf, None, namespace_store, worker_deployment_store),
221 runtime,
222 )
223 .await
224 }
225
226 async fn build_with_connected_store(
227 connected: ConnectedStore,
228 runtime: RuntimeConfig,
229 ) -> Result<Self, ServerError> {
230 let cluster_self_node = connected.cluster_self_node();
231 let outbox_store = connected.outbox_store;
232 let bootstrap_coordinator = connected.bootstrap_coordinator;
233 let cluster_responder = connected.cluster_responder;
234 let cluster_store = connected.cluster_store;
235 let watched_peers = connected.watched_peers;
236 let RoutingState {
240 shard_directory,
241 request_forwarder,
242 mint_routing,
243 } = build_routing_state(
244 cluster_store.as_ref(),
245 connected.directory_peers,
246 connected.self_node_id,
247 );
248 let (event_broadcast_capacity, query_timeout) = required_engine_seams(&runtime)?;
249 let (cluster_publisher, transcript_publisher) =
250 build_real_time_publishers(&runtime, connected.observability_store)?;
251 let (metrics, outbox_wake, instrumented_store) =
252 build_instrumented_store(&runtime, connected.event_store)?;
253 let exported_metrics = runtime.metrics.enabled.then_some(metrics.clone());
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 = boot_engine(EngineAssembly {
270 seams: &seams,
271 instrumented_store: &instrumented_store,
272 event_broadcast_capacity,
273 query_timeout,
274 activity_dispatcher,
275 active_registry: Arc::new(aion::Registry::default()),
276 bootstrap_coordinator,
277 runtime: &runtime,
278 })
279 .await?;
280 let resolver = NamespaceResolver::from_config(runtime.namespace.clone(), engine);
281 let worker_supervisor =
282 new_supervisor(&connected.worker_deployment_store, &cluster_publisher);
283 #[cfg(feature = "auth")]
284 let jwks_cache = build_jwks_cache(&runtime).await?;
285 Ok(Self {
286 inner: Arc::new(ServerStateInner {
287 namespace_guard: NamespaceGuard::new(resolver),
288 runtime,
289 metrics: exported_metrics,
290 worker_registry: seams.worker_registry,
291 pending_activities: seams.pending_activities,
292 heartbeat_tracker: seams.heartbeat_tracker,
293 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
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 declared_attempts: seams.declared_attempts,
308 update_status,
309 workspace_root,
310 cluster_self_node,
311 cluster_responder,
312 cluster_store,
313 watched_peers,
314 shard_directory,
315 request_forwarder,
316 #[cfg(feature = "auth")]
317 jwks_cache,
318 }),
319 })
320 }
321
322 #[must_use]
324 pub fn from_parts(namespace_resolver: NamespaceResolver, runtime: RuntimeConfig) -> Self {
325 Self::from_parts_with_namespace_store(
330 namespace_resolver,
331 runtime,
332 Arc::new(aion_store::InMemoryStore::default()),
333 )
334 }
335
336 #[must_use]
343 pub fn from_parts_with_namespace_store<S>(
344 namespace_resolver: NamespaceResolver,
345 runtime: RuntimeConfig,
346 store: Arc<S>,
347 ) -> Self
348 where
349 S: NamespaceStore + WorkerDeploymentStore,
350 {
351 let namespace_store: Arc<dyn NamespaceStore> = store.clone();
352 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = store;
353 Self::from_parts_with_control_stores(
354 namespace_resolver,
355 runtime,
356 namespace_store,
357 worker_deployment_store,
358 )
359 }
360
361 #[must_use]
366 pub fn from_parts_with_control_stores(
367 namespace_resolver: NamespaceResolver,
368 runtime: RuntimeConfig,
369 namespace_store: Arc<dyn NamespaceStore>,
370 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
371 ) -> Self {
372 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
373 let pending_activities = PendingActivities::new(runtime.worker.heartbeat_window);
379 let bounds = transcript_bounds(&runtime);
383 let batch = required_transcript_batch_policy(&runtime)
387 .unwrap_or(crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED);
388 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
389 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
390 );
391 Self {
392 inner: Arc::new(ServerStateInner {
393 namespace_guard: NamespaceGuard::new(namespace_resolver),
394 runtime,
395 worker_registry: ConnectedWorkerRegistry::default()
396 .with_worker_deployment_store(worker_deployment_store.clone())
397 .with_cluster_publisher(cluster_publisher.clone()),
398 pending_activities,
399 heartbeat_tracker,
400 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
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 batch,
419 ),
420 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
421 queue_service_state: crate::worker::QueueServiceState::default(),
422 queue_declarations: crate::worker::QueueDeclarationSource::default(),
423 declared_attempts: crate::worker::DeclaredCommandAttempts::new(),
427 update_status: crate::update_check::UpdateStatusState::default(),
431 workspace_root: crate::worker::WorkspaceRoot::resolve(),
435 cluster_self_node: None,
436 cluster_responder: None,
437 cluster_store: None,
438 watched_peers: Vec::new(),
439 shard_directory: None,
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 = PendingActivities::new(runtime.worker.heartbeat_window);
475 let bounds = transcript_bounds(&runtime);
479 let batch = required_transcript_batch_policy(&runtime)
483 .unwrap_or(crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED);
484 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
485 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
486 );
487 Self {
488 inner: Arc::new(ServerStateInner {
489 namespace_guard: NamespaceGuard::new(namespace_resolver),
490 runtime,
491 worker_registry: ConnectedWorkerRegistry::default()
492 .with_worker_deployment_store(worker_deployment_store.clone())
493 .with_cluster_publisher(cluster_publisher.clone()),
494 pending_activities,
495 heartbeat_tracker,
496 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
497 drain_state: DrainState::default(),
498 metrics: None,
499 health: None,
500 activity_mock_registry: None,
501 outbox_store: None,
502 namespace_store,
503 worker_supervisor: new_supervisor(&worker_deployment_store, &cluster_publisher),
504 worker_deployment_store,
505 outbox_wake: Arc::new(tokio::sync::Notify::new()),
506 cluster_publisher,
507 transcript_publisher: build_transcript_publisher(
511 None,
512 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
513 bounds,
514 batch,
515 ),
516 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
517 queue_service_state: crate::worker::QueueServiceState::default(),
518 queue_declarations: crate::worker::QueueDeclarationSource::default(),
519 declared_attempts: crate::worker::DeclaredCommandAttempts::new(),
523 update_status: crate::update_check::UpdateStatusState::default(),
527 workspace_root: crate::worker::WorkspaceRoot::resolve(),
531 cluster_self_node: None,
532 cluster_responder: None,
533 cluster_store: None,
534 watched_peers: Vec::new(),
535 shard_directory: None,
536 request_forwarder: None,
537 jwks_cache: Some(jwks_cache),
538 }),
539 }
540 }
541
542 #[cfg(feature = "auth")]
548 #[must_use]
549 pub fn from_parts_with_jwks(
550 namespace_resolver: NamespaceResolver,
551 runtime: RuntimeConfig,
552 jwks_cache: JwksCache,
553 ) -> Self {
554 let fallback_deployment_store: Arc<dyn WorkerDeploymentStore> =
558 Arc::new(aion_store::InMemoryStore::default());
559 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
560 let pending_activities = PendingActivities::new(runtime.worker.heartbeat_window);
566 let bounds = transcript_bounds(&runtime);
570 let batch = required_transcript_batch_policy(&runtime)
574 .unwrap_or(crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED);
575 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
576 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
577 );
578 Self {
579 inner: Arc::new(ServerStateInner {
580 namespace_guard: NamespaceGuard::new(namespace_resolver),
581 runtime,
582 worker_registry: ConnectedWorkerRegistry::default(),
583 pending_activities,
584 heartbeat_tracker,
585 grpc_liveness_waiters: crate::worker::GrpcLivenessWaiters::new(),
586 drain_state: DrainState::default(),
587 metrics: None,
588 health: None,
589 activity_mock_registry: None,
590 outbox_store: None,
591 namespace_store: Arc::new(aion_store::InMemoryStore::default()),
596 worker_supervisor: new_supervisor(&fallback_deployment_store, &cluster_publisher),
597 worker_deployment_store: fallback_deployment_store,
598 outbox_wake: Arc::new(tokio::sync::Notify::new()),
599 cluster_publisher,
600 transcript_publisher: build_transcript_publisher(
604 None,
605 Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
606 bounds,
607 batch,
608 ),
609 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
610 queue_service_state: crate::worker::QueueServiceState::default(),
611 queue_declarations: crate::worker::QueueDeclarationSource::default(),
612 declared_attempts: crate::worker::DeclaredCommandAttempts::new(),
616 update_status: crate::update_check::UpdateStatusState::default(),
620 workspace_root: crate::worker::WorkspaceRoot::resolve(),
624 cluster_self_node: None,
625 cluster_responder: None,
626 cluster_store: None,
627 watched_peers: Vec::new(),
628 shard_directory: None,
629 request_forwarder: None,
630 jwks_cache: Some(jwks_cache),
631 }),
632 }
633 }
634
635 #[must_use]
637 pub fn from_parts_with_registry(
638 namespace_resolver: NamespaceResolver,
639 runtime: RuntimeConfig,
640 worker_registry: ConnectedWorkerRegistry,
641 ) -> Self {
642 let fallback_deployment_store: Arc<dyn WorkerDeploymentStore> =
646 Arc::new(aion_store::InMemoryStore::default());
647 let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
648 let pending_activities = PendingActivities::new(runtime.worker.heartbeat_window);
654 let bounds = transcript_bounds(&runtime);
658 let batch = required_transcript_batch_policy(&runtime)
662 .unwrap_or(crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED);
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 batch,
696 ),
697 attempt_owners: crate::worker::AttemptOwnerIndex::new(),
698 queue_service_state: crate::worker::QueueServiceState::default(),
699 queue_declarations: crate::worker::QueueDeclarationSource::default(),
700 declared_attempts: crate::worker::DeclaredCommandAttempts::new(),
704 update_status: crate::update_check::UpdateStatusState::default(),
708 workspace_root: crate::worker::WorkspaceRoot::resolve(),
712 cluster_self_node: None,
713 cluster_responder: None,
714 cluster_store: None,
715 watched_peers: Vec::new(),
716 shard_directory: None,
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 pub fn cancel_in_flight_activities(
793 &self,
794 workflow_id: &aion_core::WorkflowId,
795 ) -> Result<crate::worker::InFlightCancellation, ServerError> {
796 crate::worker::cancel_in_flight_activities(
797 self.heartbeat_tracker(),
798 self.worker_registry(),
799 self.declared_attempts(),
800 workflow_id,
801 )
802 }
803
804 #[must_use]
809 pub fn declared_attempts(&self) -> &crate::worker::DeclaredCommandAttempts {
810 &self.inner.declared_attempts
811 }
812
813 #[must_use]
817 pub fn cluster_publisher(&self) -> &crate::cluster_publisher::ClusterEventPublisher {
818 &self.inner.cluster_publisher
819 }
820
821 #[must_use]
826 pub fn transcript_publisher(&self) -> &crate::activity_publisher::ActivityEventPublisher {
827 &self.inner.transcript_publisher
828 }
829
830 #[must_use]
834 pub fn attempt_owners(&self) -> &crate::worker::AttemptOwnerIndex {
835 &self.inner.attempt_owners
836 }
837
838 #[must_use]
841 pub fn queue_service_state(&self) -> &crate::worker::QueueServiceState {
842 &self.inner.queue_service_state
843 }
844
845 #[must_use]
849 pub fn queue_declarations(&self) -> &crate::worker::QueueDeclarationSource {
850 &self.inner.queue_declarations
851 }
852
853 pub fn unserved_queues(&self) -> Result<Vec<crate::worker::UnservedQueue>, ServerError> {
864 self.inner.queue_service_state.unserved()
865 }
866
867 pub fn unrecoverable_runs(
885 &self,
886 ) -> Result<Vec<(aion_core::WorkflowId, aion::registry::UnrecoverableRun)>, ServerError> {
887 self.engine()?
888 .registry()
889 .unrecoverable()
890 .list()
891 .map_err(ServerError::from)
892 }
893
894 #[must_use]
906 pub fn intervention_router(&self) -> crate::worker::InterventionRouter {
907 let transport: std::sync::Arc<dyn crate::worker::InterventionTransport> = {
908 #[cfg(feature = "liminal-transport")]
909 {
910 std::sync::Arc::new(crate::worker::LiminalInterventionTransport)
911 }
912 #[cfg(not(feature = "liminal-transport"))]
913 {
914 std::sync::Arc::new(NullInterventionTransport)
915 }
916 };
917 crate::worker::InterventionRouter::new(
918 self.inner.worker_registry.clone(),
919 self.inner.attempt_owners.clone(),
920 transport,
921 )
922 .with_transcript_publisher(self.inner.transcript_publisher.clone())
925 }
926
927 #[must_use]
931 pub fn cluster_self_node(&self) -> Option<&str> {
932 self.inner.cluster_self_node.as_deref()
933 }
934
935 pub fn engine(&self) -> Result<Arc<aion::Engine>, ServerError> {
948 self.inner
949 .namespace_guard
950 .resolver()
951 .engine()
952 .map(Arc::clone)
953 }
954
955 #[must_use]
957 pub fn pending_activities(&self) -> &PendingActivities {
958 &self.inner.pending_activities
959 }
960
961 #[must_use]
963 pub fn heartbeat_tracker(&self) -> &HeartbeatTracker {
964 &self.inner.heartbeat_tracker
965 }
966
967 #[must_use]
973 pub fn grpc_liveness_waiters(&self) -> &crate::worker::GrpcLivenessWaiters {
974 &self.inner.grpc_liveness_waiters
975 }
976
977 #[must_use]
979 pub fn drain_state(&self) -> &DrainState {
980 &self.inner.drain_state
981 }
982
983 #[must_use]
985 pub fn metrics(&self) -> Option<&Metrics> {
986 self.inner.metrics.as_ref()
987 }
988
989 #[must_use]
991 pub fn health(&self) -> Option<&HealthState> {
992 self.inner.health.as_ref()
993 }
994
995 #[must_use]
1000 pub fn activity_mock_registry(&self) -> Option<&ActivityMockRegistry> {
1001 self.inner.activity_mock_registry.as_ref()
1002 }
1003
1004 #[must_use]
1010 pub fn outbox_store(&self) -> Option<Arc<dyn OutboxStore>> {
1011 self.inner.outbox_store.clone()
1012 }
1013
1014 #[must_use]
1023 pub fn namespace_store(&self) -> &Arc<dyn NamespaceStore> {
1024 &self.inner.namespace_store
1025 }
1026
1027 #[must_use]
1029 pub fn worker_deployment_store(&self) -> &Arc<dyn WorkerDeploymentStore> {
1030 &self.inner.worker_deployment_store
1031 }
1032
1033 #[must_use]
1040 pub fn worker_supervisor(&self) -> &Arc<WorkerSupervisor> {
1041 &self.inner.worker_supervisor
1042 }
1043
1044 #[must_use]
1053 pub fn namespace_minter(&self) -> NamespaceMinter {
1054 let minter = NamespaceMinter::new(
1055 Arc::clone(&self.inner.namespace_store),
1056 self.inner.runtime.auto_create,
1057 )
1058 .with_cluster_publisher(self.inner.cluster_publisher.clone());
1063 match self.namespace_routing() {
1068 Some(routing) => minter.with_routing(routing),
1069 None => minter,
1070 }
1071 }
1072
1073 #[must_use]
1083 pub fn namespace_routing(&self) -> Option<crate::namespace::NamespaceRouting> {
1084 build_namespace_routing(
1085 self.cluster_store(),
1086 self.shard_directory(),
1087 self.request_forwarder(),
1088 )
1089 }
1090
1091 #[must_use]
1097 pub fn outbox_wake(&self) -> Arc<tokio::sync::Notify> {
1098 Arc::clone(&self.inner.outbox_wake)
1099 }
1100
1101 #[must_use]
1107 pub fn is_clustered(&self) -> bool {
1108 self.inner.cluster_responder.is_some()
1109 }
1110
1111 #[must_use]
1116 pub fn cluster_store(&self) -> Option<&Arc<aion_store_haematite::HaematiteStore>> {
1117 self.inner.cluster_store.as_ref()
1118 }
1119
1120 #[must_use]
1124 pub fn shard_directory(&self) -> Option<&Arc<crate::routing::StaticShardDirectory>> {
1125 self.inner.shard_directory.as_ref()
1126 }
1127
1128 #[must_use]
1131 pub fn request_forwarder(&self) -> Option<&Arc<dyn crate::routing::RequestForwarder>> {
1132 self.inner.request_forwarder.as_ref()
1133 }
1134
1135 #[must_use]
1152 pub fn spawn_heartbeat_sweeper(
1153 &self,
1154 shutdown: tokio::sync::watch::Receiver<bool>,
1155 ) -> tokio::task::JoinHandle<()> {
1156 let sweeper = crate::worker::HeartbeatSweeper::new(
1157 self.inner.heartbeat_tracker.clone(),
1158 self.inner.worker_registry.clone(),
1159 self.inner.pending_activities.clone(),
1160 self.inner.drain_state.clone(),
1161 self.inner.runtime.worker.heartbeat_window,
1162 )
1163 .with_queue_state(self.inner.queue_service_state.clone());
1167 tokio::spawn(sweeper.run(shutdown))
1168 }
1169
1170 #[cfg(feature = "liminal-transport")]
1181 #[must_use]
1182 pub fn spawn_liminal_liveness_probe(
1183 &self,
1184 notifier: std::sync::Arc<crate::worker::LiminalConnectionNotifier>,
1185 shutdown: tokio::sync::watch::Receiver<bool>,
1186 ) -> tokio::task::JoinHandle<()> {
1187 let probe = crate::worker::LivenessProbe::across_transports(
1188 Some(notifier),
1189 Some(self.inner.grpc_liveness_waiters.clone()),
1194 self.inner.heartbeat_tracker.clone(),
1195 self.inner.worker_registry.clone(),
1196 self.inner.runtime.worker.heartbeat_window,
1197 );
1198 tokio::spawn(probe.run(shutdown))
1199 }
1200
1201 pub fn spawn_cluster_supervisor(
1217 &self,
1218 config: crate::cluster::SupervisorConfig,
1219 shutdown: tokio::sync::watch::Receiver<bool>,
1220 ) -> Result<bool, ServerError> {
1221 let Some(cluster_store) = self.inner.cluster_store.clone() else {
1222 return Ok(false);
1223 };
1224 if self.inner.watched_peers.is_empty() {
1225 return Ok(false);
1226 }
1227 let engine = Arc::clone(self.inner.namespace_guard.resolver().engine()?);
1228 let publisher = Arc::new(self.inner.cluster_publisher.clone());
1232 let self_node = self.inner.cluster_self_node.clone().unwrap_or_default();
1233 let adopter = Arc::new(crate::cluster::OutboxSettlingAdopter::new(
1239 engine,
1240 self.inner.outbox_store.clone(),
1241 ));
1242 let supervisor = crate::cluster::ClusterSupervisor::new(
1243 cluster_store,
1244 adopter,
1245 self.inner.watched_peers.clone(),
1246 config,
1247 )
1248 .with_publisher(publisher, self_node);
1249 if !supervisor.watches_any() {
1250 return Ok(false);
1251 }
1252 tokio::spawn(supervisor.run(shutdown));
1253 Ok(true)
1254 }
1255
1256 #[cfg(feature = "auth")]
1258 #[must_use]
1259 pub fn jwks_cache(&self) -> Option<&JwksCache> {
1260 self.inner.jwks_cache.as_ref()
1261 }
1262
1263 pub fn shutdown(&self) -> Result<(), ServerError> {
1270 self.inner.namespace_guard.resolver().shutdown_engine()
1271 }
1272}
1273
1274#[cfg(feature = "auth")]
1275async fn build_jwks_cache(runtime: &RuntimeConfig) -> Result<Option<JwksCache>, ServerError> {
1276 if !runtime.auth.enabled {
1277 return Ok(None);
1278 }
1279 let Some(url) = runtime.auth.jwks_url.clone() else {
1280 return Err(ServerError::Config {
1281 message: "auth.jwks_url must not be empty when auth.enabled is true".to_owned(),
1282 });
1283 };
1284 let interval = std::time::Duration::from_secs(runtime.auth.jwks_refresh_seconds);
1285 let cache = JwksCache::new(url, interval)
1286 .await
1287 .map_err(|error| ServerError::Config {
1288 message: format!("auth jwks initial fetch failed: {error}"),
1289 })?;
1290 Ok(Some(cache))
1291}
1292
1293fn metrics_config_error(error: &MetricsError) -> ServerError {
1294 ServerError::Config {
1295 message: error.to_string(),
1296 }
1297}
1298
1299struct EngineAssembly<'a> {
1301 seams: &'a WorkerSeams,
1303 instrumented_store: &'a Arc<InstrumentedEventStore>,
1305 event_broadcast_capacity: std::num::NonZeroUsize,
1307 query_timeout: std::time::Duration,
1309 activity_dispatcher: Arc<dyn ActivityDispatcher>,
1311 active_registry: Arc<aion::Registry>,
1313 bootstrap_coordinator: bool,
1315 runtime: &'a RuntimeConfig,
1317}
1318
1319fn server_search_attribute_schema() -> Result<aion_core::SearchAttributeSchema, ServerError> {
1328 let mut schema = aion_core::SearchAttributeSchema::new();
1329 for (name, label) in [
1330 (crate::namespace::NAMESPACE_ATTRIBUTE, "namespace"),
1331 (crate::namespace::TASK_QUEUE_ATTRIBUTE, "task_queue"),
1332 (crate::namespace::DISPLAY_NAME_ATTRIBUTE, "display_name"),
1333 ] {
1334 schema
1335 .register(name, aion_core::SearchAttributeType::String)
1336 .map_err(|error| ServerError::Config {
1337 message: format!("failed to register {label} search attribute: {error}"),
1338 })?;
1339 }
1340 Ok(schema)
1341}
1342
1343async fn boot_engine(assembly: EngineAssembly<'_>) -> Result<Arc<aion::Engine>, ServerError> {
1353 let search_attribute_schema = server_search_attribute_schema()?;
1354 let runtime = assembly.runtime;
1355 let builder = EngineBuilder::new()
1356 .store_arc(assembly.instrumented_store.clone())
1357 .event_streaming(assembly.event_broadcast_capacity)
1358 .in_memory_visibility()
1359 .search_attribute_schema(search_attribute_schema)
1360 .scheduler_threads(runtime.scheduler_threads)
1361 .outbox_enabled(runtime.outbox.enabled)
1362 .activity_dispatcher(assembly.activity_dispatcher)
1363 .active_registry(assembly.active_registry)
1364 .production_recovery_seam()
1365 .defer_startup_recovery()
1372 .signal_router_factory(|runtime: Arc<RuntimeHandle>, handoff| {
1373 Arc::new(ConcreteSignalRouter::new(runtime, handoff)) as Arc<dyn SignalRouter>
1374 })
1375 .query_timeout(assembly.query_timeout)
1376 .bootstrap_schedule_coordinator(assembly.bootstrap_coordinator)
1382 .load_workflow_sources(runtime.workflow_packages.iter().map(PathBuf::as_path));
1383 let builder = if runtime.owned_shards.is_empty() {
1389 builder
1390 } else {
1391 builder.owned_shards(runtime.owned_shards.iter().copied())
1392 };
1393 let builder = match runtime.jit_threshold {
1399 Some(threshold) => builder.scheduler_jit_threshold(threshold),
1400 None => builder,
1401 };
1402 let engine = Arc::new(builder.build().await.map_err(ServerError::from)?);
1403 install_engine_backed_seams(assembly.seams, &engine, runtime.outbox.enabled);
1404 engine
1410 .run_startup_recovery()
1411 .await
1412 .map_err(ServerError::from)?;
1413 Ok(engine)
1414}
1415
1416fn required_engine_seams(
1421 runtime: &RuntimeConfig,
1422) -> Result<(std::num::NonZeroUsize, std::time::Duration), ServerError> {
1423 let event_broadcast_capacity = runtime
1424 .websocket
1425 .event_broadcast_capacity
1426 .and_then(std::num::NonZeroUsize::new)
1427 .ok_or_else(|| ServerError::Config {
1428 message: crate::config::EVENT_BROADCAST_CAPACITY_REQUIRED.to_owned(),
1429 })?;
1430 let query_timeout = runtime
1431 .query_timeout
1432 .filter(|timeout| !timeout.is_zero())
1433 .ok_or_else(|| ServerError::Config {
1434 message: crate::config::QUERY_TIMEOUT_REQUIRED.to_owned(),
1435 })?;
1436 Ok((event_broadcast_capacity, query_timeout))
1437}
1438
1439fn install_outbox_delivery(
1446 pending_activities: &PendingActivities,
1447 engine: &Arc<aion::Engine>,
1448 outbox_enabled: bool,
1449) {
1450 if outbox_enabled {
1451 let callback = Arc::new(crate::worker::ServerOutboxDeliveryCallback::new(
1452 Arc::clone(engine),
1453 ));
1454 pending_activities.set_outbox_delivery(callback);
1455 }
1456}
1457
1458struct WorkerSeams {
1460 worker_registry: ConnectedWorkerRegistry,
1461 pending_activities: PendingActivities,
1462 heartbeat_tracker: HeartbeatTracker,
1463 drain_state: DrainState,
1464 queue_declarations: crate::worker::QueueDeclarationSource,
1465 queue_service_state: crate::worker::QueueServiceState,
1466 declared_bodies: crate::worker::DeclaredBodySource,
1467 declared_attempts: crate::worker::DeclaredCommandAttempts,
1473 cluster_publisher: crate::cluster_publisher::ClusterEventPublisher,
1477}
1478
1479fn build_worker_seams(
1484 runtime: &RuntimeConfig,
1485 cluster_publisher: &crate::cluster_publisher::ClusterEventPublisher,
1486 namespace_store: &Arc<dyn NamespaceStore>,
1487 worker_deployment_store: &Arc<dyn WorkerDeploymentStore>,
1488 mint_routing: Option<crate::namespace::NamespaceRouting>,
1489) -> WorkerSeams {
1490 let worker_registry = ConnectedWorkerRegistry::default()
1491 .with_cluster_publisher(cluster_publisher.clone())
1492 .with_namespace_minting(namespace_store.clone(), runtime.auto_create)
1493 .with_worker_deployment_store(worker_deployment_store.clone());
1494 let worker_registry = match mint_routing {
1500 Some(routing) => worker_registry.with_namespace_routing(routing),
1501 None => worker_registry,
1502 };
1503 WorkerSeams {
1504 worker_registry,
1505 pending_activities: PendingActivities::new(runtime.worker.heartbeat_window),
1510 heartbeat_tracker: HeartbeatTracker::new(runtime.worker.heartbeat_window),
1511 drain_state: DrainState::default(),
1512 queue_declarations: crate::worker::QueueDeclarationSource::default(),
1513 queue_service_state: crate::worker::QueueServiceState::default(),
1514 declared_bodies: crate::worker::DeclaredBodySource::default(),
1515 declared_attempts: crate::worker::DeclaredCommandAttempts::new(),
1516 cluster_publisher: cluster_publisher.clone(),
1517 }
1518}
1519
1520fn install_queue_declarations(
1527 queue_declarations: &crate::worker::QueueDeclarationSource,
1528 engine: &Arc<aion::Engine>,
1529) {
1530 queue_declarations.install(Arc::new(crate::worker::EngineQueueDeclarations::new(
1531 Arc::clone(engine),
1532 )));
1533}
1534
1535fn install_declared_bodies(
1550 declared_bodies: &crate::worker::DeclaredBodySource,
1551 engine: &Arc<aion::Engine>,
1552) {
1553 declared_bodies.install(Arc::new(crate::worker::EngineDeclaredBodies::new(
1554 Arc::clone(engine),
1555 )));
1556}
1557
1558fn install_engine_backed_seams(seams: &WorkerSeams, engine: &Arc<aion::Engine>, outbox: bool) {
1564 install_outbox_delivery(&seams.pending_activities, engine, outbox);
1565 install_queue_declarations(&seams.queue_declarations, engine);
1566 install_declared_bodies(&seams.declared_bodies, engine);
1567}
1568
1569fn build_bridge_dispatcher(
1582 runtime: &RuntimeConfig,
1583 seams: &WorkerSeams,
1584) -> (WorkerActivityDispatcher, crate::worker::AttemptOwnerIndex) {
1585 let attempt_owners = crate::worker::AttemptOwnerIndex::new();
1586 let dispatcher = WorkerActivityDispatcher::new(
1587 seams.worker_registry.clone(),
1588 runtime.default_namespace.clone(),
1589 seams.heartbeat_tracker.clone(),
1590 )
1591 .with_pending(seams.pending_activities.clone())
1592 .with_drain_state(seams.drain_state.clone())
1593 .with_tokio_handle(tokio::runtime::Handle::current())
1594 .with_attempt_owners(attempt_owners.clone())
1595 .with_queue_service(runtime.worker.queue_service.clone())
1596 .with_queue_declarations(seams.queue_declarations.clone())
1597 .with_queue_state(seams.queue_service_state.clone())
1598 .with_cluster_publisher(seams.cluster_publisher.clone());
1599 (dispatcher, attempt_owners)
1600}
1601
1602fn build_instrumented_store(
1615 runtime: &RuntimeConfig,
1616 event_store: Arc<dyn EventStore>,
1617) -> Result<
1618 (
1619 Metrics,
1620 Arc<tokio::sync::Notify>,
1621 Arc<InstrumentedEventStore>,
1622 ),
1623 ServerError,
1624> {
1625 let metrics = Metrics::new().map_err(|error| metrics_config_error(&error))?;
1626 let outbox_wake = Arc::new(tokio::sync::Notify::new());
1627 let instrumented_store = Arc::new(
1628 InstrumentedEventStore::new(
1629 event_store,
1630 metrics.clone(),
1631 runtime.default_namespace.clone(),
1632 )
1633 .with_outbox_wake(Arc::clone(&outbox_wake)),
1634 );
1635 Ok((metrics, outbox_wake, instrumented_store))
1636}
1637
1638fn build_decorated_dispatcher(
1643 runtime: &RuntimeConfig,
1644 seams: &WorkerSeams,
1645 transcript: crate::activity_publisher::ActivityEventPublisher,
1646) -> (
1647 Arc<dyn ActivityDispatcher>,
1648 Option<ActivityMockRegistry>,
1649 crate::worker::AttemptOwnerIndex,
1650 crate::worker::WorkspaceRoot,
1651 crate::update_check::UpdateStatusState,
1652) {
1653 let workspace_root = crate::worker::WorkspaceRoot::resolve();
1657 let update_status = crate::update_check::UpdateStatusState::default();
1660 let (dispatcher, attempt_owners) = build_bridge_dispatcher(runtime, seams);
1661 let (activity_dispatcher, activity_mock_registry) = decorate_activity_dispatcher(
1662 dispatcher,
1663 seams.declared_bodies.clone(),
1664 seams.declared_attempts.clone(),
1665 workspace_root.clone(),
1666 transcript,
1667 update_status.clone(),
1668 runtime.dev.enabled,
1669 );
1670 (
1671 activity_dispatcher,
1672 activity_mock_registry,
1673 attempt_owners,
1674 workspace_root,
1675 update_status,
1676 )
1677}
1678
1679fn decorate_activity_dispatcher(
1680 dispatcher: WorkerActivityDispatcher,
1681 declared_bodies: crate::worker::DeclaredBodySource,
1682 declared_attempts: crate::worker::DeclaredCommandAttempts,
1683 workspace_root: crate::worker::WorkspaceRoot,
1684 transcript: crate::activity_publisher::ActivityEventPublisher,
1685 update_status: crate::update_check::UpdateStatusState,
1686 dev_enabled: bool,
1687) -> (Arc<dyn ActivityDispatcher>, Option<ActivityMockRegistry>) {
1688 let declared = crate::worker::DeclaredCommandDispatcher::new(
1696 Arc::new(dispatcher),
1697 declared_bodies.clone(),
1698 declared_attempts,
1699 tokio::runtime::Handle::current(),
1700 workspace_root,
1701 transcript,
1702 );
1703 let observed = crate::update_check::UpdateCheckObserver::new(
1704 Arc::new(declared),
1705 declared_bodies,
1706 update_status,
1707 );
1708 if dev_enabled {
1709 let registry = ActivityMockRegistry::new();
1710 let decorated = DevMockingDispatcher::new(Arc::new(observed), registry.clone());
1711 (Arc::new(decorated), Some(registry))
1712 } else {
1713 (Arc::new(observed), None)
1714 }
1715}
1716
1717fn required_cluster_broadcast_capacity(
1722 runtime: &RuntimeConfig,
1723) -> Result<std::num::NonZeroUsize, ServerError> {
1724 runtime
1725 .websocket
1726 .cluster_broadcast_capacity
1727 .and_then(std::num::NonZeroUsize::new)
1728 .ok_or_else(|| ServerError::Config {
1729 message: crate::config::CLUSTER_BROADCAST_CAPACITY_REQUIRED.to_owned(),
1730 })
1731}
1732
1733fn build_real_time_publishers(
1746 runtime: &RuntimeConfig,
1747 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1748) -> Result<
1749 (
1750 crate::cluster_publisher::ClusterEventPublisher,
1751 crate::activity_publisher::ActivityEventPublisher,
1752 ),
1753 ServerError,
1754> {
1755 let capacity = required_cluster_broadcast_capacity(runtime)?;
1756 let batch = required_transcript_batch_policy(runtime)?;
1757 Ok((
1758 crate::cluster_publisher::ClusterEventPublisher::new(capacity),
1759 build_transcript_publisher(
1760 observability_store,
1761 capacity,
1762 transcript_bounds(runtime),
1763 batch,
1764 ),
1765 ))
1766}
1767
1768fn required_node_cache_budget(
1798 config: &StoreConfig,
1799) -> Result<haematite::NodeCacheBudget, ServerError> {
1800 config.node_cache_budget.ok_or_else(|| ServerError::Config {
1801 message: crate::config::STORE_NODE_CACHE_BUDGET_REQUIRED.to_owned(),
1802 })
1803}
1804
1805fn required_lock_acquisition_ruling(
1806 config: &StoreConfig,
1807) -> Result<aion_store_haematite::LockAcquisitionRuling, ServerError> {
1808 let absent = || ServerError::Config {
1809 message: crate::config::STORE_LOCK_ACQUISITION_REQUIRED.to_owned(),
1810 };
1811 let patience_ms = config.lock_acquisition_patience_ms.ok_or_else(&absent)?;
1812 let retry_cadence_ms = config
1813 .lock_acquisition_retry_cadence_ms
1814 .ok_or_else(absent)?;
1815 aion_store_haematite::LockAcquisitionRuling::from_millis(patience_ms, retry_cadence_ms).map_err(
1820 |error| ServerError::Config {
1821 message: format!(
1822 "{}: {error}",
1823 crate::config::STORE_LOCK_ACQUISITION_REQUIRED
1824 ),
1825 },
1826 )
1827}
1828
1829fn required_transcript_batch_policy(
1830 runtime: &RuntimeConfig,
1831) -> Result<crate::activity_publisher::TranscriptBatchPolicy, ServerError> {
1832 let max_batch_events = runtime
1833 .observability
1834 .max_batch_events
1835 .and_then(std::num::NonZeroUsize::new)
1836 .ok_or_else(|| ServerError::Config {
1837 message: crate::config::OBSERVABILITY_MAX_BATCH_EVENTS_REQUIRED.to_owned(),
1838 })?;
1839 let max_batch_hold_ms =
1842 runtime
1843 .observability
1844 .max_batch_hold_ms
1845 .ok_or_else(|| ServerError::Config {
1846 message: crate::config::OBSERVABILITY_MAX_BATCH_HOLD_MS_REQUIRED.to_owned(),
1847 })?;
1848 Ok(crate::activity_publisher::TranscriptBatchPolicy {
1849 max_batch_events,
1850 max_hold: std::time::Duration::from_millis(max_batch_hold_ms),
1851 })
1852}
1853
1854fn transcript_bounds(runtime: &RuntimeConfig) -> crate::activity_bounds::TranscriptBounds {
1856 crate::activity_bounds::TranscriptBounds {
1857 max_event_bytes: runtime.observability.max_event_bytes,
1858 max_stream_events: runtime.observability.max_stream_events,
1859 }
1860}
1861
1862fn build_transcript_publisher(
1873 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1874 capacity: std::num::NonZeroUsize,
1875 bounds: crate::activity_bounds::TranscriptBounds,
1876 batch: crate::activity_publisher::TranscriptBatchPolicy,
1877) -> crate::activity_publisher::ActivityEventPublisher {
1878 let store = observability_store
1879 .unwrap_or_else(|| Arc::new(aion_store::InMemoryObservabilityStore::default()));
1880 crate::activity_publisher::ActivityEventPublisher::new(store, capacity, batch)
1881 .with_bounds(bounds)
1882}
1883
1884struct RoutingState {
1886 shard_directory: Option<Arc<crate::routing::StaticShardDirectory>>,
1887 request_forwarder: Option<Arc<dyn crate::routing::RequestForwarder>>,
1888 mint_routing: Option<crate::namespace::NamespaceRouting>,
1893}
1894
1895fn build_routing_state(
1899 cluster_store: Option<&Arc<aion_store_haematite::HaematiteStore>>,
1900 directory_peers: Vec<crate::routing::DirectoryPeer>,
1901 self_node_id: Option<String>,
1902) -> RoutingState {
1903 let Some(store) = cluster_store else {
1904 return RoutingState {
1905 shard_directory: None,
1906 request_forwarder: None,
1907 mint_routing: None,
1908 };
1909 };
1910 let shard_directory = Arc::new(crate::routing::StaticShardDirectory::new(
1911 Arc::clone(store),
1912 directory_peers,
1913 self_node_id,
1914 ));
1915 let request_forwarder: Arc<dyn crate::routing::RequestForwarder> =
1916 Arc::new(crate::routing::GrpcRequestForwarder::new());
1917 let mint_routing = build_namespace_routing(
1918 Some(store),
1919 Some(&shard_directory),
1920 Some(&request_forwarder),
1921 );
1922 RoutingState {
1923 shard_directory: Some(shard_directory),
1924 request_forwarder: Some(request_forwarder),
1925 mint_routing,
1926 }
1927}
1928
1929fn build_namespace_routing(
1939 cluster_store: Option<&Arc<aion_store_haematite::HaematiteStore>>,
1940 shard_directory: Option<&Arc<crate::routing::StaticShardDirectory>>,
1941 request_forwarder: Option<&Arc<dyn crate::routing::RequestForwarder>>,
1942) -> Option<crate::namespace::NamespaceRouting> {
1943 use crate::namespace::{
1944 GrpcMintForwarder, MintForwarder, MintShardOwners, NamespaceRouting, NamespaceShardResolver,
1945 };
1946 let store = Arc::clone(cluster_store?);
1947 let directory = Arc::clone(shard_directory?);
1948 let shards: Arc<dyn NamespaceShardResolver> = store;
1949 let owners: Arc<dyn MintShardOwners> = directory;
1950 let forwarder: Arc<dyn MintForwarder> =
1951 Arc::new(GrpcMintForwarder::new(Arc::clone(request_forwarder?)));
1952 Some(NamespaceRouting::new(shards, owners, forwarder))
1953}
1954
1955struct ConnectedStore {
1965 event_store: Arc<dyn EventStore>,
1966 outbox_store: Option<Arc<dyn OutboxStore>>,
1967 namespace_store: Arc<dyn NamespaceStore>,
1974 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
1976 observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1984 bootstrap_coordinator: bool,
1985 cluster_responder: Option<aion_store_haematite::ClusterResponder>,
1986 cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
1990 watched_peers: Vec<crate::cluster::WatchedPeer>,
1993 directory_peers: Vec<crate::routing::DirectoryPeer>,
1997 self_node_id: Option<String>,
2001}
2002
2003impl ConnectedStore {
2004 fn local(
2011 event_store: Arc<dyn EventStore>,
2012 outbox_store: Option<Arc<dyn OutboxStore>>,
2013 namespace_store: Arc<dyn NamespaceStore>,
2014 worker_deployment_store: Arc<dyn WorkerDeploymentStore>,
2015 ) -> Self {
2016 Self {
2017 event_store,
2018 outbox_store,
2019 namespace_store,
2020 worker_deployment_store,
2021 observability_store: None,
2027 bootstrap_coordinator: true,
2028 cluster_responder: None,
2029 cluster_store: None,
2030 watched_peers: Vec::new(),
2031 directory_peers: Vec::new(),
2032 self_node_id: None,
2033 }
2034 }
2035
2036 fn cluster_self_node(&self) -> Option<String> {
2039 self.self_node_id.clone()
2040 }
2041}
2042
2043async fn connect_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
2046 match config.backend {
2047 StoreBackend::Memory => {
2048 let leaf = Arc::new(aion_store::InMemoryStore::default());
2051 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
2052 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
2053 Ok(ConnectedStore::local(
2054 leaf,
2055 None,
2056 namespace_store,
2057 worker_deployment_store,
2058 ))
2059 }
2060 StoreBackend::Haematite => connect_haematite_store(config).await,
2061 }
2062}
2063
2064async fn connect_haematite_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
2084 let node_cache_budget = required_node_cache_budget(&config)?;
2090 let lock_acquisition = required_lock_acquisition_ruling(&config)?;
2091 let Some(data_dir) = config.data_dir else {
2092 return Err(ServerError::Config {
2093 message: "store.data_dir must not be empty when store.backend is haematite".to_owned(),
2094 });
2095 };
2096 let shard_count = config.shard_count;
2097 let owned_shards = config.owned_shards.clone();
2098 let cluster = config.cluster.clone();
2099 let watched_peers: Vec<crate::cluster::WatchedPeer> = cluster
2104 .as_ref()
2105 .map(|cluster| {
2106 cluster
2107 .peers
2108 .iter()
2109 .map(|peer| crate::cluster::WatchedPeer {
2110 name: peer.name.clone(),
2111 owned_shards: peer.owned_shards.clone(),
2112 })
2113 .collect()
2114 })
2115 .unwrap_or_default();
2116 let directory_peers: Vec<crate::routing::DirectoryPeer> = cluster
2119 .as_ref()
2120 .map(|cluster| {
2121 cluster
2122 .peers
2123 .iter()
2124 .map(|peer| crate::routing::DirectoryPeer {
2125 name: peer.name.clone(),
2126 owned_shards: peer.owned_shards.clone(),
2127 grpc_addr: peer.grpc_address,
2128 })
2129 .collect()
2130 })
2131 .unwrap_or_default();
2132 let self_node_id: Option<String> = cluster.as_ref().map(|cluster| cluster.node_id.clone());
2135 let (store, responder) = tokio::task::spawn_blocking(move || {
2139 build_haematite_store(
2140 &data_dir,
2141 shard_count,
2142 cluster,
2143 node_cache_budget,
2144 lock_acquisition,
2145 )
2146 })
2147 .await
2148 .map_err(|error| ServerError::Config {
2149 message: format!("haematite store initialization task failed: {error}"),
2150 })??;
2151
2152 let bootstrap_coordinator = if owned_shards.is_empty() {
2156 true
2157 } else {
2158 store.set_owned_shards(owned_shards.iter().copied());
2159 store.owns_workflow_shard(&aion::schedule_coordinator_workflow_id())
2160 };
2161
2162 let leaf = Arc::new(store);
2163 let event_store: Arc<dyn EventStore> = leaf.clone();
2164 let outbox_store: Arc<dyn OutboxStore> = leaf.clone();
2165 let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
2169 let worker_deployment_store: Arc<dyn WorkerDeploymentStore> = leaf.clone();
2170 let observability_store: Arc<dyn aion_store::ObservabilityStore> = leaf.clone();
2175 let cluster_store = responder.as_ref().map(|_| leaf);
2179 let (watched_peers, directory_peers, self_node_id) = if cluster_store.is_some() {
2180 (watched_peers, directory_peers, self_node_id)
2181 } else {
2182 (Vec::new(), Vec::new(), None)
2183 };
2184 Ok(ConnectedStore {
2185 event_store,
2186 outbox_store: Some(outbox_store),
2187 namespace_store,
2188 worker_deployment_store,
2189 observability_store: Some(observability_store),
2190 bootstrap_coordinator,
2191 cluster_responder: responder,
2192 cluster_store,
2193 watched_peers,
2194 directory_peers,
2195 self_node_id,
2196 })
2197}
2198
2199fn build_haematite_store(
2214 data_dir: &str,
2215 shard_count: usize,
2216 cluster: Option<crate::config::ClusterConfig>,
2217 node_cache_budget: haematite::NodeCacheBudget,
2218 lock_acquisition: aion_store_haematite::LockAcquisitionRuling,
2219) -> Result<
2220 (
2221 aion_store_haematite::HaematiteStore,
2222 Option<aion_store_haematite::ClusterResponder>,
2223 ),
2224 ServerError,
2225> {
2226 build_haematite_store_with_hook(
2227 data_dir,
2228 shard_count,
2229 cluster,
2230 node_cache_budget,
2231 lock_acquisition,
2232 || Ok(()),
2233 )
2234}
2235
2236fn build_haematite_store_with_hook(
2237 data_dir: &str,
2238 shard_count: usize,
2239 cluster: Option<crate::config::ClusterConfig>,
2240 node_cache_budget: haematite::NodeCacheBudget,
2241 lock_acquisition: aion_store_haematite::LockAcquisitionRuling,
2242 before_backend_touch: impl FnOnce() -> Result<(), std::io::Error>,
2243) -> Result<
2244 (
2245 aion_store_haematite::HaematiteStore,
2246 Option<aion_store_haematite::ClusterResponder>,
2247 ),
2248 ServerError,
2249> {
2250 use aion_store_haematite::{ClusterBootstrap, HaematiteStore};
2251
2252 let private_root = crate::filesystem::ConfinedDir::open_or_create(std::path::Path::new(
2260 data_dir,
2261 ))
2262 .map_err(|error| ServerError::Config {
2263 message: format!("unsafe store.data_dir `{data_dir}`: {error}"),
2264 })?;
2265
2266 for shard in 0..shard_count {
2270 private_root
2271 .create_dir_all(std::path::Path::new(&format!("shard-{shard}")))
2272 .map_err(|error| ServerError::Config {
2273 message: format!(
2274 "failed to materialize shard-{shard} under store.data_dir `{data_dir}`: {error}"
2275 ),
2276 })?;
2277 }
2278 private_root
2279 .harden_tree()
2280 .map_err(|error| private_store_mode_error(data_dir, &error))?;
2281
2282 before_backend_touch().map_err(|error| ServerError::Config {
2285 message: format!("store.data_dir pre-open hook failed: {error}"),
2286 })?;
2287
2288 #[cfg(unix)]
2289 let backend_path = private_root
2290 .backend_path()
2291 .map_err(|error| ServerError::Config {
2292 message: format!("failed to resolve held store.data_dir `{data_dir}`: {error}"),
2293 })?;
2294 #[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
2295 crate::filesystem::validate_ambient_backend_ancestors(&backend_path).map_err(|error| {
2296 let (component, reason) = error.into_parts();
2297 ServerError::UnsafeDataRootAncestor {
2298 data_root: backend_path.clone(),
2299 component,
2300 reason,
2301 }
2302 })?;
2303 #[cfg(not(unix))]
2304 let backend_path = std::path::PathBuf::from(data_dir);
2305
2306 let Some(cluster) = cluster else {
2307 let store = if backend_path.join("config.json").exists() {
2308 HaematiteStore::open(&backend_path, node_cache_budget, lock_acquisition)
2313 .map_err(ServerError::from)?
2314 } else {
2315 HaematiteStore::create_with_shard_count(&backend_path, shard_count, node_cache_budget)
2316 .map_err(ServerError::from)?
2317 };
2318 store.materialize_all_shards().map_err(ServerError::from)?;
2319 private_root
2320 .harden_tree()
2321 .map_err(|error| private_store_mode_error(data_dir, &error))?;
2322 let store = store.retain_data_root_capability(private_root);
2323 return Ok((store, None));
2324 };
2325
2326 let boot = ClusterBootstrap {
2327 node_id: cluster.node_id,
2328 bind_address: cluster.bind_address,
2329 members: cluster.members,
2330 peers: cluster
2331 .peers
2332 .into_iter()
2333 .map(|peer| (peer.name, peer.address))
2334 .collect(),
2335 timeout: HAEMATITE_CLUSTER_OP_TIMEOUT,
2336 };
2337 let (store, responder) = HaematiteStore::open_or_create_distributed(
2338 &backend_path,
2339 shard_count,
2340 boot,
2341 node_cache_budget,
2342 )
2343 .map_err(ServerError::from)?;
2344 store.materialize_all_shards().map_err(ServerError::from)?;
2345 private_root
2346 .harden_tree()
2347 .map_err(|error| private_store_mode_error(data_dir, &error))?;
2348 let store = store.retain_data_root_capability(private_root);
2349 Ok((store, Some(responder)))
2350}
2351
2352fn private_store_mode_error(data_dir: &str, error: &std::io::Error) -> ServerError {
2353 ServerError::Config {
2354 message: format!(
2355 "failed to apply private modes under store.data_dir `{data_dir}`: {error}"
2356 ),
2357 }
2358}
2359
2360const HAEMATITE_CLUSTER_OP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
2362
2363#[cfg(not(feature = "liminal-transport"))]
2372#[derive(Clone, Debug)]
2373struct NullInterventionTransport;
2374
2375#[cfg(not(feature = "liminal-transport"))]
2376#[async_trait::async_trait]
2377impl crate::worker::InterventionTransport for NullInterventionTransport {
2378 async fn push(
2379 &self,
2380 _worker: &crate::worker::WorkerHandle,
2381 _command: aion_core::InterventionCommand,
2382 ) -> Result<aion_core::InterventionOutcome, ServerError> {
2383 Err(ServerError::worker_connection_lost(
2384 "intervention",
2385 "no intervention push transport is compiled in".to_owned(),
2386 ))
2387 }
2388}
2389
2390#[cfg(test)]
2391mod tests {
2392 use std::{net::SocketAddr, time::Duration};
2393
2394 use aion_store::InMemoryStore;
2395
2396 use super::ServerState;
2397 use crate::config::{
2398 AuthConfig, AuthoringConfig, DeployConfig, DevConfig, ListenConfig, MetricsConfig,
2399 NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig, OutboxConfig,
2400 RuntimeConfig, WebSocketConfig, WorkerConfig,
2401 };
2402
2403 fn runtime_config() -> RuntimeConfig {
2404 RuntimeConfig {
2405 listen: ListenConfig {
2406 grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
2407 http: SocketAddr::from(([127, 0, 0, 1], 8080)),
2408 },
2409 tls: None,
2410 auth: AuthConfig {
2411 enabled: false,
2412 jwks_url: None,
2413 jwks_refresh_seconds: 300,
2414 },
2415 ops_console: OpsConsoleConfig {
2416 source: OpsConsoleAssetSource::Embedded,
2417 },
2418 namespace: NamespaceConfig {
2419 mode: NamespaceMode::SharedEngine,
2420 },
2421 worker: WorkerConfig {
2422 heartbeat_window: Duration::from_secs(30),
2423 ..WorkerConfig::default()
2424 },
2425 websocket: WebSocketConfig {
2426 outbound_buffer_bound: 32,
2427 event_broadcast_capacity: Some(64),
2428 cluster_broadcast_capacity: Some(64),
2429 },
2430 workflow_packages: Vec::new(),
2431 deploy: DeployConfig::default(),
2432 authoring: AuthoringConfig::default(),
2433 dev: DevConfig::default(),
2434 outbox: OutboxConfig::default(),
2435 observability: crate::config::ObservabilityConfig::with_flush_policy(64, 0),
2436 mcp: crate::config::ResolvedMcpConfig::default(),
2437 scheduler_threads: 1,
2438 jit_threshold: None,
2439 query_timeout: Some(Duration::from_secs(10)),
2440 default_namespace: "default".to_owned(),
2441 auto_create: crate::config::AutoCreate::Open,
2442 max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
2443 drain_timeout: Duration::from_secs(30),
2444 metrics: MetricsConfig { enabled: true },
2445 owned_shards: Vec::new(),
2446 cors_allowed_origins: Vec::new(),
2447 }
2448 }
2449
2450 #[test]
2455 fn an_unruled_flush_policy_refuses_to_build_the_publisher() {
2456 for (mutate, expected_key) in [
2457 (
2458 Box::new(|runtime: &mut RuntimeConfig| {
2459 runtime.observability.max_batch_events = None;
2460 }) as Box<dyn Fn(&mut RuntimeConfig)>,
2461 "observability.max_batch_events",
2462 ),
2463 (
2464 Box::new(|runtime: &mut RuntimeConfig| {
2465 runtime.observability.max_batch_events = Some(0);
2466 }),
2467 "observability.max_batch_events",
2468 ),
2469 (
2470 Box::new(|runtime: &mut RuntimeConfig| {
2471 runtime.observability.max_batch_hold_ms = None;
2472 }),
2473 "observability.max_batch_hold_ms",
2474 ),
2475 ] {
2476 let mut runtime = runtime_config();
2477 mutate(&mut runtime);
2478 let message = super::required_transcript_batch_policy(&runtime)
2479 .err()
2480 .map_or_else(String::new, |error| error.to_string());
2481 assert!(
2482 message.contains(expected_key),
2483 "the refusal must name {expected_key}: {message}"
2484 );
2485 assert!(
2486 message.contains("no default"),
2487 "and must say the key has no default: {message}"
2488 );
2489 }
2490 }
2491
2492 #[test]
2495 fn a_stated_flush_policy_is_carried_through_verbatim() -> Result<(), Box<dyn std::error::Error>>
2496 {
2497 let mut runtime = runtime_config();
2498 runtime.observability = crate::config::ObservabilityConfig::with_flush_policy(32, 0);
2499 let policy = super::required_transcript_batch_policy(&runtime)?;
2500 assert_eq!(policy.max_batch_events.get(), 32);
2501 assert_eq!(policy.max_hold, Duration::ZERO);
2502
2503 runtime.observability = crate::config::ObservabilityConfig::with_flush_policy(8, 250);
2504 let policy = super::required_transcript_batch_policy(&runtime)?;
2505 assert_eq!(policy.max_batch_events.get(), 8);
2506 assert_eq!(policy.max_hold, Duration::from_millis(250));
2507 Ok(())
2508 }
2509
2510 #[test]
2521 fn engine_schema_accepts_every_attribute_the_start_writer_records()
2522 -> Result<(), Box<dyn std::error::Error>> {
2523 let schema = super::server_search_attribute_schema()?;
2524 let recorded = crate::api::handlers::workflows::start_search_attributes(
2525 "tenant-a",
2526 Some("gpu"),
2527 Some("Nightly settlement"),
2528 );
2529
2530 assert!(
2531 recorded.contains_key(crate::namespace::DISPLAY_NAME_ATTRIBUTE),
2532 "the fixture must exercise the display-name attribute, or this test \
2533 cannot see its registration go missing"
2534 );
2535 for (name, value) in &recorded {
2536 schema.validate(name, value).map_err(|error| {
2537 format!(
2538 "the start writer records {name}, but the engine's schema refuses it: {error}"
2539 )
2540 })?;
2541 }
2542 Ok(())
2543 }
2544
2545 #[tokio::test]
2546 async fn builds_state_with_in_memory_store() -> Result<(), Box<dyn std::error::Error>> {
2547 let state =
2548 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2549
2550 std::hint::black_box(state.namespace_guard());
2551 std::hint::black_box(state.worker_registry());
2552
2553 Ok(())
2554 }
2555
2556 #[tokio::test]
2564 async fn unserved_queues_surfaces_a_parked_dispatch_and_clears_it()
2565 -> Result<(), Box<dyn std::error::Error>> {
2566 use aion::{ActivityDispatch, ActivityDispatcher as _};
2567 use aion_core::{ActivityId, RunId, WorkflowId};
2568 use std::sync::Arc;
2569
2570 let state =
2571 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2572 assert!(
2573 state.unserved_queues()?.is_empty(),
2574 "a calm boot has no unserved queues"
2575 );
2576 assert!(state.queue_declarations().is_installed());
2580 assert_eq!(
2581 state
2582 .queue_declarations()
2583 .declaration_for("nobody-serves-this"),
2584 crate::worker::QueueDeclaration::Unknown
2585 );
2586
2587 let dispatcher = Arc::new(
2588 crate::worker::WorkerActivityDispatcher::new(
2589 state.worker_registry().clone(),
2590 "default",
2591 crate::worker::HeartbeatTracker::new(Duration::from_secs(5)),
2592 )
2593 .with_queue_state(state.queue_service_state().clone())
2594 .with_queue_declarations(state.queue_declarations().clone()),
2595 );
2596 let workflow_id = WorkflowId::new_v4();
2597 let request = ActivityDispatch {
2598 namespace: "default".to_owned(),
2599 task_queue: "nobody-serves-this".to_owned(),
2600 node: None,
2601 workflow_id: workflow_id.clone(),
2602 run_id: RunId::new_v4(),
2603 activity_id: ActivityId::from_sequence_position(0),
2604 name: "greet".to_owned(),
2605 input: "{}".to_owned(),
2606 config: "{}".to_owned(),
2607 attempt: 1,
2608 advisory: false,
2609 labels: std::collections::BTreeMap::new(),
2610 };
2611 let parked = std::thread::spawn(move || dispatcher.dispatch(request));
2612
2613 let mut unserved = Vec::new();
2614 for _ in 0..30 {
2615 unserved = state.unserved_queues()?;
2616 if !unserved.is_empty() {
2617 break;
2618 }
2619 tokio::time::sleep(Duration::from_millis(100)).await;
2620 }
2621 assert_eq!(unserved.len(), 1, "the parked dispatch is not surfaced");
2622 assert_eq!(
2623 unserved[0].reason,
2624 crate::worker::QueueServiceReason::NoLivePollers,
2625 "an empty catalog must not be read as a structural refusal"
2626 );
2627 assert_eq!(unserved[0].key.task_queue, "nobody-serves-this");
2628 assert_eq!(unserved[0].waiting.len(), 1);
2629 assert_eq!(unserved[0].waiting[0].workflow_id, workflow_id);
2630
2631 let (worker_tx, worker_rx) = tokio::sync::mpsc::channel(1);
2633 drop(worker_rx);
2634 let registration = state.worker_registry().register_namespaces(
2635 [String::from("default")],
2636 "nobody-serves-this",
2637 None,
2638 [String::from("greet")].iter(),
2639 worker_tx,
2640 )?;
2641 let outcome = parked.join().map_err(|_| "parked dispatch panicked")?;
2642 assert!(outcome.is_err(), "the released dispatch must resolve");
2643 assert!(
2644 state.unserved_queues()?.is_empty(),
2645 "a resolved dispatch must leave the unserved state"
2646 );
2647 registration.deregister()?;
2648 Ok(())
2649 }
2650
2651 #[tokio::test]
2652 async fn namespace_store_is_reachable_and_functional_after_default_boot()
2653 -> Result<(), Box<dyn std::error::Error>> {
2654 use aion_store::{MintOutcome, NamespaceOrigin};
2655
2656 let state =
2660 ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2661
2662 let store = state.namespace_store();
2663
2664 let outcome = store
2666 .register_namespace("orders", NamespaceOrigin::WorkerMint)
2667 .await?;
2668 assert_eq!(
2669 outcome,
2670 MintOutcome::Created,
2671 "the first reference to a namespace mints it"
2672 );
2673
2674 let again = store
2676 .register_namespace("orders", NamespaceOrigin::WorkerMint)
2677 .await?;
2678 assert_eq!(
2679 again,
2680 MintOutcome::AlreadyExisted,
2681 "a second reference touches the existing record rather than re-creating it"
2682 );
2683
2684 let fetched = store.get_namespace("orders").await?;
2686 let record = fetched.ok_or("registered namespace must be retrievable via get_namespace")?;
2687 assert_eq!(record.name, "orders");
2688 assert_eq!(record.origin, NamespaceOrigin::WorkerMint);
2689
2690 let listed = store.list_namespaces().await?;
2692 assert!(
2693 listed.iter().any(|record| record.name == "orders"),
2694 "list_namespaces returns the minted namespace"
2695 );
2696
2697 Ok(())
2698 }
2699
2700 #[tokio::test(flavor = "multi_thread")]
2701 async fn connect_store_haematite_round_trips_through_event_store()
2702 -> Result<(), Box<dyn std::error::Error>> {
2703 use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
2704 use aion_store::WriteToken;
2705 use chrono::Utc;
2706
2707 use crate::config::{StoreBackend, StoreConfig};
2708
2709 let data_dir = crate::test_support::private_tempdir()?;
2710 let connected = super::connect_store(StoreConfig {
2714 backend: StoreBackend::Haematite,
2715 owned_shards: Vec::new(),
2716 data_dir: Some(data_dir.path().to_string_lossy().into_owned()),
2717 shard_count: 1,
2718 cluster: None,
2719 node_cache_budget: Some(test_node_cache_budget()?),
2720 lock_acquisition_patience_ms: Some(250),
2721 lock_acquisition_retry_cadence_ms: Some(5),
2722 ..StoreConfig::default()
2723 })
2724 .await?;
2725 let event_store = connected.event_store;
2726 assert!(
2727 connected.outbox_store.is_some(),
2728 "the haematite backend shares its leaf store as the dispatcher's outbox store"
2729 );
2730 assert!(
2731 connected.bootstrap_coordinator,
2732 "a single-node haematite boot owns all shards and bootstraps the coordinator"
2733 );
2734 assert!(
2735 connected.cluster_responder.is_none(),
2736 "a single-node (no [cluster]) haematite boot has no distributed responder"
2737 );
2738
2739 let workflow_id = WorkflowId::new_v4();
2740 let event = aion_core::Event::WorkflowStarted {
2741 envelope: EventEnvelope {
2742 seq: 1,
2743 recorded_at: Utc::now(),
2744 workflow_id: workflow_id.clone(),
2745 },
2746 workflow_type: String::from("checkout"),
2747 input: Payload::new(ContentType::Json, b"{}".to_vec()),
2748 run_id: RunId::new_v4(),
2749 parent_run_id: None,
2750 parent_workflow_id: None,
2751 package_version: PackageVersion::new("a".repeat(64)),
2752 };
2753 event_store
2754 .append(
2755 WriteToken::recorder(),
2756 &workflow_id,
2757 std::slice::from_ref(&event),
2758 0,
2759 )
2760 .await?;
2761 let history = event_store.read_history(&workflow_id).await?;
2762 assert_eq!(
2763 history.len(),
2764 1,
2765 "an event appended through the server's dyn EventStore reads back"
2766 );
2767 Ok(())
2768 }
2769
2770 fn test_node_cache_budget() -> Result<haematite::NodeCacheBudget, Box<dyn std::error::Error>> {
2776 Ok(haematite::NodeCacheBudget::bytes(1 << 30)?)
2777 }
2778
2779 fn test_lock_acquisition()
2780 -> Result<aion_store_haematite::LockAcquisitionRuling, Box<dyn std::error::Error>> {
2781 Ok(aion_store_haematite::LockAcquisitionRuling::from_millis(
2786 250, 5,
2787 )?)
2788 }
2789
2790 #[cfg(unix)]
2791 #[test]
2792 fn haematite_root_swap_before_first_backend_touch_cannot_redirect_writes()
2793 -> Result<(), Box<dyn std::error::Error>> {
2794 use std::os::unix::fs::symlink;
2795
2796 let sandbox = crate::test_support::private_tempdir()?;
2797 let configured_root = sandbox.path().join("data");
2798 let held_root = sandbox.path().join("held-data");
2799 let outside = sandbox.path().join("outside");
2800 std::fs::create_dir(&outside)?;
2801 let configured = configured_root
2802 .to_str()
2803 .ok_or("temporary data path was not UTF-8")?;
2804
2805 let (store, responder) = super::build_haematite_store_with_hook(
2806 configured,
2807 4,
2808 None,
2809 test_node_cache_budget()?,
2810 test_lock_acquisition()?,
2811 || {
2812 std::fs::rename(&configured_root, &held_root)?;
2817 symlink(&outside, &configured_root)?;
2818 Ok(())
2819 },
2820 )?;
2821 assert!(responder.is_none());
2822
2823 let outside_entries = std::fs::read_dir(&outside)?.collect::<Result<Vec<_>, _>>()?;
2824 assert!(
2825 outside_entries.is_empty(),
2826 "Haematite followed the replaced ambient root and wrote outside"
2827 );
2828 assert!(held_root.join("config.json").is_file());
2829 for shard in 0..4 {
2830 let shard_path = held_root.join(format!("shard-{shard}"));
2831 assert!(shard_path.is_dir(), "shard {shard} was not materialized");
2832 assert!(
2833 std::fs::read_dir(&shard_path)?
2834 .next()
2835 .transpose()?
2836 .is_some(),
2837 "shard {shard} did not run Haematite's materialization path"
2838 );
2839 }
2840
2841 drop(store);
2842 Ok(())
2843 }
2844
2845 #[cfg(any(target_os = "linux", target_os = "android"))]
2846 #[tokio::test]
2847 async fn proc_fd_backend_path_survives_a_post_startup_root_swap()
2848 -> Result<(), Box<dyn std::error::Error>> {
2849 use std::os::unix::fs::symlink;
2850
2851 use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
2852 use aion_store::{WritableEventStore as _, WriteToken};
2853 use chrono::Utc;
2854
2855 let sandbox = crate::test_support::private_tempdir()?;
2856 let configured_root = sandbox.path().join("data");
2857 let held_root = sandbox.path().join("held-data");
2858 let capture = sandbox.path().join("capture");
2859 std::fs::create_dir(&capture)?;
2860 let configured = configured_root
2861 .to_str()
2862 .ok_or("temporary data path was not UTF-8")?;
2863
2864 let (store, responder) = super::build_haematite_store(
2865 configured,
2866 4,
2867 None,
2868 test_node_cache_budget()?,
2869 test_lock_acquisition()?,
2870 )?;
2871 assert!(responder.is_none());
2872 std::fs::rename(&configured_root, &held_root)?;
2873 symlink(&capture, &configured_root)?;
2874
2875 let workflow_id = WorkflowId::new_v4();
2876 let event = aion_core::Event::WorkflowStarted {
2877 envelope: EventEnvelope {
2878 seq: 1,
2879 recorded_at: Utc::now(),
2880 workflow_id: workflow_id.clone(),
2881 },
2882 workflow_type: String::from("post-startup-root-swap"),
2883 input: Payload::new(ContentType::Json, b"{}".to_vec()),
2884 run_id: RunId::new_v4(),
2885 parent_run_id: None,
2886 parent_workflow_id: None,
2887 package_version: PackageVersion::new("a".repeat(64)),
2888 };
2889 store
2890 .append(
2891 WriteToken::recorder(),
2892 &workflow_id,
2893 std::slice::from_ref(&event),
2894 0,
2895 )
2896 .await?;
2897
2898 let captured = std::fs::read_dir(&capture)?.collect::<Result<Vec<_>, _>>()?;
2899 assert!(
2900 captured.is_empty(),
2901 "post-startup append followed the replacement symlink into capture"
2902 );
2903 assert!(held_root.join("config.json").is_file());
2904 drop(store);
2905 Ok(())
2906 }
2907
2908 #[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
2909 #[test]
2910 fn path_ambient_haematite_refuses_group_or_world_writable_ancestors()
2911 -> Result<(), Box<dyn std::error::Error>> {
2912 use std::os::unix::fs::PermissionsExt as _;
2913
2914 let sandbox = crate::test_support::private_tempdir()?;
2915 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2916
2917 for mode in [0o770, 0o1777] {
2918 let shared = sandbox.path().join(format!("shared-{mode:o}"));
2919 let data_root = shared.join("data");
2920 std::fs::create_dir(&shared)?;
2921 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(mode))?;
2922 std::fs::create_dir(&data_root)?;
2923 std::fs::set_permissions(&data_root, std::fs::Permissions::from_mode(0o700))?;
2924 let configured = data_root
2925 .to_str()
2926 .ok_or("temporary data path was not UTF-8")?;
2927
2928 let Err(error) = super::build_haematite_store(
2929 configured,
2930 4,
2931 None,
2932 test_node_cache_budget()?,
2933 test_lock_acquisition()?,
2934 ) else {
2935 return Err(format!("mode {mode:04o} ancestor was accepted").into());
2936 };
2937 let message = error.to_string();
2938 let crate::ServerError::UnsafeDataRootAncestor {
2939 data_root: resolved_root,
2940 component,
2941 reason,
2942 } = error
2943 else {
2944 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
2945 };
2946 assert_eq!(resolved_root, std::fs::canonicalize(&data_root)?);
2947 assert_eq!(component, std::fs::canonicalize(&shared)?);
2948 assert!(
2949 reason.contains(&format!("mode {mode:04o}")),
2950 "unexpected reason: {reason}"
2951 );
2952 if mode & 0o1000 != 0 {
2953 assert!(reason.contains("sticky bit is not accepted"));
2954 }
2955 assert!(message.contains("private Aion home"));
2956 assert!(
2957 !data_root.join("config.json").exists(),
2958 "Haematite touched its ambient path before the refusal"
2959 );
2960 }
2961 Ok(())
2962 }
2963
2964 #[cfg(target_os = "macos")]
2965 #[test]
2966 fn path_ambient_haematite_refuses_mutating_allow_acl_ancestor()
2967 -> Result<(), Box<dyn std::error::Error>> {
2968 use std::os::unix::fs::PermissionsExt as _;
2969
2970 let sandbox = crate::test_support::private_tempdir()?;
2971 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2972 let shared = sandbox.path().join("acl-shared");
2973 let data_root = shared.join("data");
2974 std::fs::create_dir(&shared)?;
2975 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o700))?;
2976 let acl = "everyone allow list,search,add_file,add_subdirectory,delete_child";
2977 let status = std::process::Command::new("chmod")
2978 .arg("+a")
2979 .arg(acl)
2980 .arg(&shared)
2981 .status()?;
2982 assert!(status.success(), "failed to install Darwin regression ACL");
2983 let configured = data_root
2984 .to_str()
2985 .ok_or("temporary data path was not UTF-8")?;
2986
2987 let result = super::build_haematite_store(
2988 configured,
2989 4,
2990 None,
2991 test_node_cache_budget()?,
2992 test_lock_acquisition()?,
2993 );
2994 let cleanup = std::process::Command::new("chmod")
2995 .arg("-RN")
2996 .arg(&shared)
2997 .status()?;
2998 assert!(cleanup.success(), "failed to clean Darwin regression ACL");
2999
3000 let Err(error) = result else {
3001 return Err("mutating non-euid allow ACL ancestor was accepted".into());
3002 };
3003 let message = error.to_string();
3004 let crate::ServerError::UnsafeDataRootAncestor {
3005 component, reason, ..
3006 } = error
3007 else {
3008 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
3009 };
3010 assert_eq!(component, std::fs::canonicalize(&shared)?);
3011 assert!(
3012 reason.contains("allow"),
3013 "reason did not name the ACE: {reason}"
3014 );
3015 assert!(
3016 reason.contains("everyone"),
3017 "reason did not name the ACE principal: {reason}"
3018 );
3019 assert!(
3020 !data_root.join("config.json").exists(),
3021 "Haematite touched its ambient path before the ACL refusal"
3022 );
3023 Ok(())
3024 }
3025
3026 #[cfg(target_os = "macos")]
3027 #[test]
3028 fn path_ambient_haematite_accepts_the_euid_uuid_allow_ace()
3029 -> Result<(), Box<dyn std::error::Error>> {
3030 use std::os::unix::fs::PermissionsExt as _;
3031
3032 use exacl::{AclEntry, AclOption, Perm};
3033
3034 let sandbox = crate::test_support::private_tempdir()?;
3035 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3036 let private_parent = sandbox.path().join("euid-uuid-allow");
3037 let data_root = private_parent.join("data");
3038 std::fs::create_dir(&private_parent)?;
3039 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
3040
3041 let server_uid = rustix::process::geteuid().as_raw();
3042 let ace_qualifier = crate::filesystem::darwin_user_uuid_for_test(server_uid)?;
3043 let entry = AclEntry::allow_user(
3044 &ace_qualifier.to_string(),
3045 Perm::EXECUTE | Perm::WRITE | Perm::APPEND | Perm::DELETE_CHILD,
3046 None,
3047 );
3048 exacl::setfacl(
3049 &[private_parent.as_path()],
3050 &[entry],
3051 AclOption::SYMLINK_ACL,
3052 )?;
3053 let configured = data_root
3054 .to_str()
3055 .ok_or("temporary data path was not UTF-8")?;
3056
3057 let result = super::build_haematite_store(
3058 configured,
3059 4,
3060 None,
3061 test_node_cache_budget()?,
3062 test_lock_acquisition()?,
3063 );
3064 let cleanup = std::process::Command::new("chmod")
3065 .arg("-RN")
3066 .arg(&private_parent)
3067 .status()?;
3068 assert!(cleanup.success(), "failed to clean euid UUID allow ACL");
3069
3070 let (store, responder) = result?;
3071 assert!(responder.is_none());
3072 assert!(data_root.join("config.json").is_file());
3073 drop(store);
3074 Ok(())
3075 }
3076
3077 #[cfg(target_os = "macos")]
3078 #[test]
3079 fn path_ambient_haematite_refuses_a_non_euid_user_uuid_allow_ace()
3080 -> Result<(), Box<dyn std::error::Error>> {
3081 use std::os::unix::fs::PermissionsExt as _;
3082
3083 use exacl::{AclEntry, AclOption, Perm};
3084
3085 let sandbox = crate::test_support::private_tempdir()?;
3086 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3087 let shared = sandbox.path().join("non-euid-uuid-allow");
3088 let data_root = shared.join("data");
3089 std::fs::create_dir(&shared)?;
3090 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o700))?;
3091
3092 let server_uid = rustix::process::geteuid().as_raw();
3093 let foreign_uid = u32::from(server_uid == 0);
3094 let foreign_qualifier = crate::filesystem::darwin_user_uuid_for_test(foreign_uid)?;
3095 let entry = AclEntry::allow_user(
3096 &foreign_qualifier.to_string(),
3097 Perm::EXECUTE | Perm::WRITE | Perm::APPEND | Perm::DELETE_CHILD,
3098 None,
3099 );
3100 exacl::setfacl(&[shared.as_path()], &[entry], AclOption::SYMLINK_ACL)?;
3101 let configured = data_root
3102 .to_str()
3103 .ok_or("temporary data path was not UTF-8")?;
3104
3105 let result = super::build_haematite_store(
3106 configured,
3107 4,
3108 None,
3109 test_node_cache_budget()?,
3110 test_lock_acquisition()?,
3111 );
3112 let cleanup = std::process::Command::new("chmod")
3113 .arg("-RN")
3114 .arg(&shared)
3115 .status()?;
3116 assert!(cleanup.success(), "failed to clean non-euid UUID allow ACL");
3117
3118 let Err(error) = result else {
3119 return Err("mutating non-euid user UUID allow ACE was accepted".into());
3120 };
3121 let message = error.to_string();
3122 let crate::ServerError::UnsafeDataRootAncestor {
3123 component, reason, ..
3124 } = error
3125 else {
3126 return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
3127 };
3128 assert_eq!(component, std::fs::canonicalize(&shared)?);
3129 assert!(
3130 reason.contains("allow") && reason.contains(&format!("server euid {server_uid}")),
3131 "reason did not name the rejected ACE: {reason}"
3132 );
3133 assert!(
3134 !data_root.join("config.json").exists(),
3135 "Haematite touched its ambient path before the UUID ACL refusal"
3136 );
3137 Ok(())
3138 }
3139
3140 #[cfg(target_os = "macos")]
3141 #[test]
3142 fn path_ambient_haematite_accepts_a_deny_only_acl_ancestor()
3143 -> Result<(), Box<dyn std::error::Error>> {
3144 use std::os::unix::fs::PermissionsExt as _;
3145
3146 let sandbox = crate::test_support::private_tempdir()?;
3147 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3148 let private_parent = sandbox.path().join("deny-only");
3149 let data_root = private_parent.join("data");
3150 std::fs::create_dir(&private_parent)?;
3151 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
3152 let status = std::process::Command::new("chmod")
3153 .arg("+a")
3154 .arg("everyone deny delete")
3155 .arg(&private_parent)
3156 .status()?;
3157 assert!(status.success(), "failed to install Darwin deny-only ACL");
3158 let configured = data_root
3159 .to_str()
3160 .ok_or("temporary data path was not UTF-8")?;
3161
3162 let result = super::build_haematite_store(
3163 configured,
3164 4,
3165 None,
3166 test_node_cache_budget()?,
3167 test_lock_acquisition()?,
3168 );
3169 let cleanup = std::process::Command::new("chmod")
3170 .arg("-RN")
3171 .arg(&private_parent)
3172 .status()?;
3173 assert!(cleanup.success(), "failed to clean Darwin deny-only ACL");
3174
3175 let (store, responder) = result?;
3176 assert!(responder.is_none());
3177 assert!(data_root.join("config.json").is_file());
3178 drop(store);
3179 Ok(())
3180 }
3181
3182 #[cfg(target_os = "macos")]
3183 #[test]
3184 fn path_ambient_haematite_accepts_the_stock_home_acl_chain()
3185 -> Result<(), Box<dyn std::error::Error>> {
3186 use std::os::unix::fs::PermissionsExt as _;
3187 use users::os::unix::UserExt as _;
3188
3189 let effective_uid = rustix::process::geteuid().as_raw();
3190 let effective_user = users::get_user_by_uid(effective_uid)
3191 .ok_or_else(|| format!("server euid {effective_uid} has no account record"))?;
3192 let sandbox = tempfile::Builder::new()
3193 .prefix(".aion-acl-home-proof-")
3194 .tempdir_in(effective_user.home_dir())?;
3195 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3196 let data_root = sandbox.path().join("data");
3197 let configured = data_root
3198 .to_str()
3199 .ok_or("temporary data path was not UTF-8")?;
3200
3201 let (store, responder) = super::build_haematite_store(
3202 configured,
3203 4,
3204 None,
3205 test_node_cache_budget()?,
3206 test_lock_acquisition()?,
3207 )?;
3208 assert!(responder.is_none());
3209 assert!(data_root.join("config.json").is_file());
3210 drop(store);
3211 Ok(())
3212 }
3213
3214 #[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
3215 #[test]
3216 fn path_ambient_haematite_accepts_an_owner_controlled_chain()
3217 -> Result<(), Box<dyn std::error::Error>> {
3218 use std::os::unix::fs::PermissionsExt as _;
3219
3220 let sandbox = crate::test_support::private_tempdir()?;
3221 std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
3222 let private_parent = sandbox.path().join("private");
3223 let data_root = private_parent.join("data");
3224 std::fs::create_dir(&private_parent)?;
3225 std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
3226 let configured = data_root
3227 .to_str()
3228 .ok_or("temporary data path was not UTF-8")?;
3229
3230 let (store, responder) = super::build_haematite_store(
3231 configured,
3232 4,
3233 None,
3234 test_node_cache_budget()?,
3235 test_lock_acquisition()?,
3236 )?;
3237 assert!(responder.is_none());
3238 assert!(data_root.join("config.json").is_file());
3239 for shard in 0..4 {
3240 assert!(data_root.join(format!("shard-{shard}")).is_dir());
3241 }
3242 drop(store);
3243 Ok(())
3244 }
3245
3246 #[tokio::test]
3247 async fn connect_store_memory_backend_exposes_no_outbox_store()
3248 -> Result<(), Box<dyn std::error::Error>> {
3249 use crate::config::{StoreBackend, StoreConfig};
3250
3251 let connected = super::connect_store(StoreConfig {
3254 backend: StoreBackend::Memory,
3255 owned_shards: Vec::new(),
3256 data_dir: None,
3257 shard_count: 1,
3258 cluster: None,
3259 node_cache_budget: None,
3260 ..StoreConfig::default()
3261 })
3262 .await?;
3263 assert!(
3264 connected.outbox_store.is_none(),
3265 "the in-memory backend exposes no outbox store"
3266 );
3267 Ok(())
3268 }
3269
3270 #[tokio::test]
3276 async fn haematite_boot_refuses_without_a_node_cache_budget()
3277 -> Result<(), Box<dyn std::error::Error>> {
3278 use crate::ServerError;
3279 use crate::config::{StoreBackend, StoreConfig};
3280
3281 let sandbox = crate::test_support::private_tempdir()?;
3282 let data_dir = sandbox.path().join("data");
3283 let error = super::connect_haematite_store(StoreConfig {
3284 backend: StoreBackend::Haematite,
3285 data_dir: Some(
3286 data_dir
3287 .to_str()
3288 .ok_or("temporary data path was not UTF-8")?
3289 .to_owned(),
3290 ),
3291 shard_count: 4,
3292 ..StoreConfig::default()
3293 })
3294 .await
3295 .err()
3296 .ok_or("the haematite boot path must refuse a store config with no node_cache_budget")?;
3297 let ServerError::Config { message } = error else {
3298 return Err(format!("expected a config refusal, got {error:?}").into());
3299 };
3300 assert!(
3301 message.contains("store.node_cache_budget"),
3302 "the refusal must name the missing key, got: {message}"
3303 );
3304 assert!(
3305 message.contains("AION_STORE_NODE_CACHE_BUDGET"),
3306 "the refusal must name the environment override, got: {message}"
3307 );
3308 Ok(())
3309 }
3310
3311 #[tokio::test]
3316 async fn configured_node_cache_budget_reaches_the_created_database()
3317 -> Result<(), Box<dyn std::error::Error>> {
3318 use crate::config::{StoreBackend, StoreConfig};
3319
3320 const ONE_GIB: usize = 1 << 30;
3321
3322 let sandbox = crate::test_support::private_tempdir()?;
3323 let data_dir = sandbox.path().join("data");
3324 let connected = super::connect_haematite_store(StoreConfig {
3325 backend: StoreBackend::Haematite,
3326 data_dir: Some(
3327 data_dir
3328 .to_str()
3329 .ok_or("temporary data path was not UTF-8")?
3330 .to_owned(),
3331 ),
3332 shard_count: 4,
3333 node_cache_budget: Some(haematite::NodeCacheBudget::bytes(ONE_GIB)?),
3334 lock_acquisition_patience_ms: Some(250),
3335 lock_acquisition_retry_cadence_ms: Some(5),
3336 ..StoreConfig::default()
3337 })
3338 .await?;
3339 drop(connected);
3340
3341 let recorded: serde_json::Value =
3342 serde_json::from_slice(&std::fs::read(data_dir.join("config.json"))?)?;
3343 assert_eq!(
3344 recorded.get("node_cache_budget"),
3345 Some(&serde_json::json!({ "bytes": ONE_GIB })),
3346 "the operator's budget must be the one haematite created the database with"
3347 );
3348 Ok(())
3349 }
3350
3351 #[tokio::test]
3352 async fn state_build_fails_without_event_broadcast_capacity()
3353 -> Result<(), Box<dyn std::error::Error>> {
3354 let mut runtime = runtime_config();
3355 runtime.websocket.event_broadcast_capacity = None;
3356
3357 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
3358 .await
3359 .err()
3360 .ok_or("state build must fail when event streaming is unsized")?;
3361
3362 assert!(error.is_config(), "expected a config error, got {error}");
3363 assert!(
3364 error
3365 .to_string()
3366 .contains("websocket.event_broadcast_capacity"),
3367 "error must name the missing key: {error}"
3368 );
3369 Ok(())
3370 }
3371
3372 #[tokio::test]
3373 async fn state_build_fails_without_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
3374 let mut runtime = runtime_config();
3375 runtime.query_timeout = None;
3376
3377 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
3378 .await
3379 .err()
3380 .ok_or("state build must fail when the query reply deadline is unset")?;
3381
3382 assert!(error.is_config(), "expected a config error, got {error}");
3383 assert!(
3384 error.to_string().contains("runtime.query_timeout_ms"),
3385 "error must name the missing key: {error}"
3386 );
3387 assert!(
3388 error.to_string().contains("AION_RUNTIME_QUERY_TIMEOUT_MS"),
3389 "error must name the environment override: {error}"
3390 );
3391 Ok(())
3392 }
3393
3394 #[tokio::test]
3395 async fn state_build_fails_with_zero_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
3396 let mut runtime = runtime_config();
3397 runtime.query_timeout = Some(Duration::ZERO);
3398
3399 let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
3400 .await
3401 .err()
3402 .ok_or("state build must fail when the query reply deadline is zero")?;
3403
3404 assert!(error.is_config(), "expected a config error, got {error}");
3405 assert!(
3406 error.to_string().contains("runtime.query_timeout_ms"),
3407 "error must name the zero-valued key: {error}"
3408 );
3409 Ok(())
3410 }
3411
3412 #[tokio::test(flavor = "multi_thread")]
3435 async fn a_completed_check_through_the_built_dispatcher_lands_in_the_returned_slot()
3436 -> Result<(), Box<dyn std::error::Error>> {
3437 use std::collections::{BTreeMap, VecDeque};
3438 use std::sync::{Arc, Mutex};
3439
3440 use aion::ActivityDispatch;
3441 use aion_core::{ActivityId, RunId, WorkflowId};
3442 use aion_package::ActionBodyContract;
3443
3444 use crate::update_check::document::{FETCH_ACTION, FETCH_COMMAND, UPDATE_CHECK_QUEUE};
3445 use crate::worker::{DeclaredBodies, DeclaredBodyLookup, DispatchingRun};
3446
3447 struct SequencedBodies {
3450 replies: Mutex<VecDeque<DeclaredBodyLookup>>,
3451 }
3452
3453 impl DeclaredBodies for SequencedBodies {
3454 fn body_for(
3455 &self,
3456 _task_queue: &str,
3457 _action: &str,
3458 _run: DispatchingRun<'_>,
3459 ) -> DeclaredBodyLookup {
3460 let mut replies = match self.replies.lock() {
3461 Ok(replies) => replies,
3462 Err(poisoned) => poisoned.into_inner(),
3463 };
3464 replies.pop_front().unwrap_or(DeclaredBodyLookup::None)
3465 }
3466 }
3467
3468 let runtime = runtime_config();
3469 let cluster_publisher = crate::cluster_publisher::ClusterEventPublisher::new(
3470 ServerState::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
3471 );
3472 let namespace_store: Arc<dyn aion_store::NamespaceStore> =
3473 Arc::new(InMemoryStore::default());
3474 let worker_deployment_store: Arc<dyn aion_store::WorkerDeploymentStore> =
3475 Arc::new(InMemoryStore::default());
3476 let seams = super::build_worker_seams(
3477 &runtime,
3478 &cluster_publisher,
3479 &namespace_store,
3480 &worker_deployment_store,
3481 None,
3482 );
3483
3484 let fixture = concat!(
3487 env!("CARGO_MANIFEST_DIR"),
3488 "/src/update_check/fixtures/aion-cli-index.jsonl"
3489 );
3490 seams.declared_bodies.install(Arc::new(SequencedBodies {
3491 replies: Mutex::new(VecDeque::from([
3492 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
3493 command: FETCH_COMMAND.to_owned(),
3494 }),
3495 DeclaredBodyLookup::Declared(ActionBodyContract::Run {
3496 command: format!("cat {fixture}"),
3497 }),
3498 ])),
3499 }));
3500
3501 let transcript = crate::activity_publisher::ActivityEventPublisher::new(
3502 Arc::new(aion_store::InMemoryObservabilityStore::default()),
3503 ServerState::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
3504 crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED,
3505 );
3506 let (dispatcher, _mock_registry, _attempt_owners, _workspace_root, update_status) =
3507 super::build_decorated_dispatcher(&runtime, &seams, transcript);
3508
3509 assert_eq!(
3510 update_status.last(),
3511 None,
3512 "the returned slot must start honestly empty"
3513 );
3514
3515 let dispatch = ActivityDispatch {
3516 namespace: "default".to_owned(),
3517 task_queue: UPDATE_CHECK_QUEUE.to_owned(),
3518 node: None,
3519 workflow_id: WorkflowId::new_v4(),
3520 run_id: RunId::new_v4(),
3521 activity_id: ActivityId::from_sequence_position(1),
3522 name: FETCH_ACTION.to_owned(),
3523 input: "{}".to_owned(),
3524 config: "{}".to_owned(),
3525 attempt: 1,
3526 labels: BTreeMap::new(),
3527 advisory: false,
3528 };
3529 let handle = tokio::task::spawn_blocking(move || dispatcher.dispatch(dispatch));
3530 let encoded = handle
3531 .await?
3532 .map_err(|error| format!("the check dispatch failed: {error}"))?;
3533 let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
3534 assert_eq!(outcome["exit_code"], 0, "the local stand-in command ran");
3535
3536 let recorded = update_status.last().ok_or(
3537 "the completed check must land in the RETURNED slot — the one the boot path \
3538 stores and /update-status serves; an empty slot here is the disconnected-\
3539 producer mis-wire the r1 review proved unmeasured",
3540 )?;
3541 assert_eq!(recorded.latest_known, "0.13.7");
3542 Ok(())
3543 }
3544}