1use std::{net::SocketAddr, process::ExitCode};
12
13use tokio::net::TcpListener;
14use tonic::transport::Server as TonicServer;
15use tracing::{error, info, warn};
16
17use std::sync::Arc;
18
19use crate::{
20 ServerConfig, ServerError, ServerState, api,
21 config::{CliOverrides, NamespaceMode, OutboxConfig, OutboxTransport, StoreBackend},
22 observability,
23 shutdown::{self, ShutdownOutcome},
24 worker::{
25 ActivityDispatcher, OutboxDispatcher, OutboxDispatcherConfig, OutboxReconciler,
26 OutboxReconcilerConfig, OutboxRowDispatch, WorkerOutboxDispatch,
27 },
28};
29
30#[derive(Debug, Default)]
41struct OutboxWorkerListener {
42 #[cfg(feature = "liminal-transport")]
46 _inner: Option<liminal_server::server::listener::ServerListener>,
47}
48
49pub async fn run(overrides: CliOverrides) -> ExitCode {
58 match run_server(overrides).await {
59 Ok(code) => code,
60 Err(error) => {
61 error!(%error, "aion-server failed");
62 if error.is_config() {
63 ExitCode::from(2)
64 } else {
65 ExitCode::FAILURE
66 }
67 }
68 }
69}
70
71async fn run_server(cli: CliOverrides) -> Result<ExitCode, ServerError> {
72 observability::tracing::init()?;
73
74 let config = ServerConfig::load(&cli)?;
75 reject_auth_without_feature(&config)?;
76 let store_backend = config.store.backend;
77 let owned_shards = config.store.owned_shards.clone();
83 let outbox_config = config.outbox.clone();
88 #[cfg(feature = "haematite-backend")]
92 let cluster_config = config.store.cluster.clone();
93 let state = ServerState::build(config).await?;
94 reject_tls_until_supported(&state)?;
95
96 let runtime = state.runtime_config();
97 let grpc_address = runtime.listen.grpc;
98 let http_address = runtime.listen.http;
99 let workflow_packages: Vec<String> = runtime
100 .workflow_packages
101 .iter()
102 .map(|path| path.display().to_string())
103 .collect();
104 info!(
105 version = env!("CARGO_PKG_VERSION"),
106 grpc_address = %grpc_address,
107 http_address = %http_address,
108 default_namespace = %runtime.default_namespace,
109 namespace_mode = namespace_mode_label(&runtime.namespace.mode),
110 store_backend = store_backend_label(store_backend),
111 auth_enabled = runtime.auth.enabled,
112 deploy_enabled = runtime.deploy.enabled,
113 metrics_enabled = runtime.metrics.enabled,
114 workflow_package_count = workflow_packages.len(),
115 workflow_packages = ?workflow_packages,
116 owned_shards = ?owned_shards,
117 owns_all_shards = owned_shards.is_empty(),
118 "aion-server startup banner"
119 );
120 let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
121 #[cfg(feature = "haematite-backend")]
131 let outbox_clustered = cluster_config.is_some();
132 #[cfg(not(feature = "haematite-backend"))]
133 let outbox_clustered = false;
134 let _outbox_worker_listener =
141 maybe_spawn_outbox_dispatcher(&state, &outbox_config, outbox_clustered, &shutdown_rx)?;
142 #[cfg(feature = "haematite-backend")]
147 maybe_spawn_cluster_supervisor(&state, cluster_config.as_ref(), &shutdown_rx)?;
148 let mut grpc = tokio::spawn(serve_grpc(state.clone(), grpc_address, shutdown_rx.clone()));
149 let mut http = tokio::spawn(serve_http(state.clone(), http_address, shutdown_rx));
150
151 let outcome = tokio::select! {
152 result = &mut grpc => {
153 transport_result("gRPC", result)?;
154 state.shutdown()?;
155 ShutdownOutcome::Clean
156 },
157 result = &mut http => {
158 transport_result("HTTP", result)?;
159 state.shutdown()?;
160 ShutdownOutcome::Clean
161 },
162 result = shutdown_signal() => {
163 result?;
164 let _receiver_count = shutdown_tx.send(true);
165 let outcome = shutdown::drain_after_first_signal(state.clone(), async {
166 let _ = shutdown_signal().await;
167 }).await?;
168 if !matches!(outcome, ShutdownOutcome::Forced) {
169 transport_result("gRPC", grpc.await)?;
170 transport_result("HTTP", http.await)?;
171 }
172 outcome
173 },
174 };
175
176 Ok(outcome.exit_code())
177}
178
179fn transport_result(
180 transport: &'static str,
181 result: Result<Result<(), ServerError>, tokio::task::JoinError>,
182) -> Result<(), ServerError> {
183 match result {
184 Ok(transport_outcome) => transport_outcome,
185 Err(join_error) => Err(ServerError::Transport {
186 transport,
187 message: join_error.to_string(),
188 }),
189 }
190}
191
192async fn serve_grpc(
193 state: ServerState,
194 address: SocketAddr,
195 shutdown: tokio::sync::watch::Receiver<bool>,
196) -> Result<(), ServerError> {
197 let workflow = api::grpc::workflow_service(state.clone());
198 let worker = api::worker_grpc::worker_service(state.clone());
199 let mut router = TonicServer::builder()
200 .add_service(workflow)
201 .add_service(worker);
202 if state.runtime_config().deploy.enabled {
205 router = router.add_service(api::deploy_grpc::deploy_service(state)?);
206 }
207 router
208 .serve_with_shutdown(address, shutdown_requested(shutdown))
209 .await
210 .map_err(|source| transport_bind("grpc", address, source))?;
211 Ok(())
212}
213
214async fn serve_http(
215 state: ServerState,
216 address: SocketAddr,
217 shutdown: tokio::sync::watch::Receiver<bool>,
218) -> Result<(), ServerError> {
219 let listener = TcpListener::bind(address)
220 .await
221 .map_err(|source| transport_bind("http", address, source))?;
222 axum::serve(listener, api::http::http_router(state)?)
223 .with_graceful_shutdown(shutdown_requested(shutdown))
224 .await
225 .map_err(|source| transport_bind("http", address, source))?;
226 Ok(())
227}
228
229async fn shutdown_requested(mut shutdown: tokio::sync::watch::Receiver<bool>) {
230 while !*shutdown.borrow_and_update() {
231 if shutdown.changed().await.is_err() {
232 break;
233 }
234 }
235}
236
237async fn shutdown_signal() -> Result<(), ServerError> {
238 #[cfg(unix)]
239 {
240 use tokio::signal::unix::{SignalKind, signal};
241
242 let mut terminate = signal(SignalKind::terminate())
243 .map_err(|source| signal_listener("SIGTERM", &source))?;
244 let mut interrupt =
245 signal(SignalKind::interrupt()).map_err(|source| signal_listener("SIGINT", &source))?;
246 tokio::select! {
247 _ = terminate.recv() => Ok(()),
248 _ = interrupt.recv() => Ok(()),
249 }
250 }
251
252 #[cfg(not(unix))]
253 {
254 tokio::signal::ctrl_c()
255 .await
256 .map_err(|source| signal_listener("shutdown signal", &source))
257 }
258}
259
260fn signal_listener(listener: &'static str, source: &std::io::Error) -> ServerError {
261 ServerError::SignalListener {
262 listener,
263 message: source.to_string(),
264 }
265}
266
267fn reject_auth_without_feature(config: &ServerConfig) -> Result<(), ServerError> {
268 if cfg!(not(feature = "auth")) && config.auth.enabled {
269 return Err(ServerError::Config {
270 message: "auth.enabled=true but binary compiled without auth feature".to_owned(),
271 });
272 }
273 Ok(())
274}
275
276fn maybe_spawn_outbox_dispatcher(
293 state: &ServerState,
294 outbox_config: &OutboxConfig,
295 clustered: bool,
296 shutdown_rx: &tokio::sync::watch::Receiver<bool>,
297) -> Result<OutboxWorkerListener, ServerError> {
298 if !outbox_config.enabled {
299 return Ok(OutboxWorkerListener::default());
300 }
301 let dispatcher_config = resolve_outbox_config(outbox_config)?;
302 let outbox_store = state.outbox_store().ok_or_else(|| ServerError::Config {
310 message: "outbox.enabled=true requires store.backend=libsql or store.backend=haematite: \
311 the durable outbox dispatcher claims rows from the store's outbox table, which \
312 the in-memory store does not provide"
313 .to_owned(),
314 })?;
315 let (row_dispatch, worker_listener) = select_outbox_row_dispatch(state, outbox_config)?;
316 let dispatcher =
321 OutboxDispatcher::new(Arc::clone(&outbox_store), row_dispatch, dispatcher_config)
322 .with_wake(state.outbox_wake());
323 tokio::spawn(dispatcher.run(shutdown_rx.clone()));
324 info!(
329 clustered,
330 "outbox dispatcher commissioned (active-active per-shard ownership enforced by claim scope \
331 when clustered; single-node owns all shards)"
332 );
333 if let Some(reconciler_config) = resolve_outbox_reconciler_config(outbox_config)? {
340 let reconciler = OutboxReconciler::new(outbox_store, reconciler_config);
341 tokio::spawn(reconciler.run(shutdown_rx.clone()));
342 info!("outbox reconciler commissioned");
343 } else if clustered {
344 warn!(
345 "outbox reconciler is UNCONFIGURED on a clustered boot (outbox.reconcile_interval_ms \
346 and outbox.reconcile_stale_after_ms are both unset): in-flight recovery after an \
347 owner is killed is then bounded only by re-residency replay on the adopting node, \
348 not by a stale-claim backstop; set both knobs to bound stale-claim recovery latency"
349 );
350 }
351 Ok(worker_listener)
352}
353
354#[cfg(feature = "haematite-backend")]
363fn maybe_spawn_cluster_supervisor(
364 state: &ServerState,
365 cluster_config: Option<&crate::config::ClusterConfig>,
366 shutdown_rx: &tokio::sync::watch::Receiver<bool>,
367) -> Result<(), ServerError> {
368 let Some(cluster) = cluster_config else {
369 return Ok(());
370 };
371 let poll_interval = std::time::Duration::from_millis(
372 cluster
373 .failover_poll_interval_ms
374 .unwrap_or(crate::config::DEFAULT_FAILOVER_POLL_INTERVAL_MS),
375 );
376 let confirmations = cluster
377 .failover_confirmations
378 .unwrap_or(crate::config::DEFAULT_FAILOVER_CONFIRMATIONS);
379 let supervisor_config = crate::cluster::SupervisorConfig {
380 poll_interval,
381 confirmations,
382 };
383 let spawned = state.spawn_cluster_supervisor(supervisor_config, shutdown_rx.clone())?;
384 if spawned {
385 info!(
386 poll_interval_ms = %poll_interval.as_millis(),
387 confirmations,
388 "SS-5b cluster supervisor commissioned (automatic peer-down failover)"
389 );
390 }
391 Ok(())
392}
393
394fn select_outbox_row_dispatch(
407 state: &ServerState,
408 outbox_config: &OutboxConfig,
409) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
410 match outbox_config.transport {
411 OutboxTransport::Grpc => {
412 let push_dispatcher = ActivityDispatcher::new(state.worker_registry().clone())
413 .with_drain_state(state.drain_state().clone());
414 let dispatch: Arc<dyn OutboxRowDispatch> =
415 Arc::new(WorkerOutboxDispatch::new(push_dispatcher));
416 Ok((dispatch, OutboxWorkerListener::default()))
417 }
418 OutboxTransport::Liminal => build_liminal_row_dispatch(state, outbox_config),
419 }
420}
421
422#[cfg(feature = "liminal-transport")]
456fn build_liminal_row_dispatch(
457 state: &ServerState,
458 outbox_config: &OutboxConfig,
459) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
460 use liminal_server::config::ServerConfig as LiminalServerConfig;
461 use liminal_server::server::connection::{ConnectionSupervisor, LiminalConnectionServices};
462 use liminal_server::server::listener::ServerListener;
463
464 use crate::worker::{
465 LiminalConnectionNotifier, RegistryLiminalDispatch, ServerOutboxDeliveryCallback,
466 };
467
468 let listen_address = outbox_config
469 .liminal_listen_address
470 .as_ref()
471 .ok_or_else(|| ServerError::Config {
472 message: "outbox.transport=liminal requires outbox.liminal_listen_address \
473 (host:port the aion-server listens on for inbound liminal worker \
474 connections)"
475 .to_owned(),
476 })?;
477 let listen_address: SocketAddr =
478 listen_address
479 .parse()
480 .map_err(|error| ServerError::Config {
481 message: format!(
482 "outbox.liminal_listen_address must be a host:port socket address: {error}"
483 ),
484 })?;
485
486 let liminal_config = LiminalServerConfig {
493 listen_address,
494 health_listen_address: listen_address,
495 drain_timeout_ms: 30_000,
496 channels: Vec::new(),
497 routing_rules: Vec::new(),
498 persistence_path: None,
499 cluster: None,
500 };
501
502 let registry = state.worker_registry().clone();
505 let notifier = Arc::new(LiminalConnectionNotifier::new(registry.clone()));
507 let services = Arc::new(
509 LiminalConnectionServices::from_config(&liminal_config).map_err(|error| {
510 ServerError::Config {
511 message: format!("liminal connection services build failed: {error}"),
512 }
513 })?,
514 );
515 let supervisor = ConnectionSupervisor::with_services_and_notifier(services, notifier.clone())
517 .map_err(|error| ServerError::Config {
518 message: format!("liminal connection supervisor build failed: {error}"),
519 })?;
520 if !notifier.bind_supervisor(supervisor.clone()) {
523 return Err(ServerError::Config {
524 message: "liminal notifier supervisor handle was already bound during boot".to_owned(),
525 });
526 }
527 let listener =
529 ServerListener::bind(&liminal_config, supervisor).map_err(|error| ServerError::Config {
530 message: format!("liminal worker listener failed to bind {listen_address}: {error}"),
531 })?;
532 let engine = state.engine()?;
536 let callback: Arc<dyn crate::worker::OutboxDeliveryCallback> =
537 Arc::new(ServerOutboxDeliveryCallback::new(engine));
538 let dispatch: Arc<dyn OutboxRowDispatch> =
541 Arc::new(RegistryLiminalDispatch::new(registry, callback));
542
543 info!(
544 listen_address = %listen_address,
545 "liminal outbox worker listener commissioned (remote workers connect in and self-register)"
546 );
547 Ok((
548 dispatch,
549 OutboxWorkerListener {
550 _inner: Some(listener),
551 },
552 ))
553}
554
555#[cfg(not(feature = "liminal-transport"))]
559fn build_liminal_row_dispatch(
560 _state: &ServerState,
561 _outbox_config: &OutboxConfig,
562) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
563 Err(ServerError::Config {
564 message: "outbox.transport=liminal requires the aion-server `liminal-transport` \
565 Cargo feature, which is not enabled in this build"
566 .to_owned(),
567 })
568}
569
570fn resolve_outbox_config(outbox: &OutboxConfig) -> Result<OutboxDispatcherConfig, ServerError> {
575 let poll_interval_ms = outbox.poll_interval_ms.ok_or_else(|| ServerError::Config {
576 message: crate::config::OUTBOX_POLL_INTERVAL_REQUIRED.to_owned(),
577 })?;
578 let batch_size = outbox.batch_size.ok_or_else(|| ServerError::Config {
579 message: crate::config::OUTBOX_BATCH_SIZE_REQUIRED.to_owned(),
580 })?;
581 let max_attempts = outbox.max_attempts.ok_or_else(|| ServerError::Config {
582 message: crate::config::OUTBOX_MAX_ATTEMPTS_REQUIRED.to_owned(),
583 })?;
584 let backoff_base_ms = outbox.backoff_base_ms.ok_or_else(|| ServerError::Config {
585 message: crate::config::OUTBOX_BACKOFF_BASE_REQUIRED.to_owned(),
586 })?;
587 let backoff_multiplier = outbox
588 .backoff_multiplier
589 .ok_or_else(|| ServerError::Config {
590 message: crate::config::OUTBOX_BACKOFF_MULTIPLIER_REQUIRED.to_owned(),
591 })?;
592 let backoff_max_ms = outbox.backoff_max_ms.ok_or_else(|| ServerError::Config {
593 message: crate::config::OUTBOX_BACKOFF_MAX_REQUIRED.to_owned(),
594 })?;
595 Ok(OutboxDispatcherConfig {
596 poll_interval: std::time::Duration::from_millis(poll_interval_ms),
597 batch_size,
598 max_attempts,
599 backoff_base: std::time::Duration::from_millis(backoff_base_ms),
600 backoff_multiplier,
601 backoff_max: std::time::Duration::from_millis(backoff_max_ms),
602 })
603}
604
605fn resolve_outbox_reconciler_config(
606 outbox: &OutboxConfig,
607) -> Result<Option<OutboxReconcilerConfig>, ServerError> {
608 let (Some(interval_ms), Some(stale_after_ms)) = (
609 outbox.reconcile_interval_ms,
610 outbox.reconcile_stale_after_ms,
611 ) else {
612 return Ok(None);
613 };
614 let batch_size = outbox.batch_size.ok_or_else(|| ServerError::Config {
615 message: crate::config::OUTBOX_BATCH_SIZE_REQUIRED.to_owned(),
616 })?;
617 Ok(Some(OutboxReconcilerConfig {
618 interval: std::time::Duration::from_millis(interval_ms),
619 stale_after: std::time::Duration::from_millis(stale_after_ms),
620 batch_size,
621 }))
622}
623
624fn reject_tls_until_supported(state: &ServerState) -> Result<(), ServerError> {
625 if state.runtime_config().tls.is_some() {
626 return Err(ServerError::Config {
627 message: "configured TLS material cannot be served until transport TLS is wired"
628 .to_owned(),
629 });
630 }
631 Ok(())
632}
633
634fn store_backend_label(backend: StoreBackend) -> &'static str {
635 match backend {
636 StoreBackend::Memory => "memory",
637 StoreBackend::LibSql => "libsql",
638 StoreBackend::Haematite => "haematite",
639 }
640}
641
642fn namespace_mode_label(mode: &NamespaceMode) -> &'static str {
643 match mode {
644 NamespaceMode::SharedEngine => "SharedEngine",
645 NamespaceMode::SingleTenant { .. } => "SingleTenant",
646 }
647}
648
649fn transport_bind<E>(transport: &'static str, address: SocketAddr, source: E) -> ServerError
650where
651 E: std::error::Error,
652{
653 ServerError::TransportBind {
654 transport,
655 address,
656 message: source.to_string(),
657 }
658}
659
660#[cfg(test)]
661mod tests {
662 #![allow(clippy::expect_used)]
663
664 use super::{
665 OutboxConfig, OutboxTransport, maybe_spawn_outbox_dispatcher,
666 resolve_outbox_reconciler_config,
667 };
668 use crate::ServerState;
669 use crate::config::RuntimeConfig;
670 use aion_store::InMemoryStore;
671 use std::net::SocketAddr;
672 use std::time::Duration;
673
674 fn runtime_config() -> RuntimeConfig {
677 use crate::config::{
678 AuthConfig, AuthoringConfig, DashboardAssetSource, DashboardConfig, DeployConfig,
679 DevConfig, ListenConfig, MetricsConfig, NamespaceConfig, NamespaceMode,
680 WebSocketConfig, WorkerConfig,
681 };
682 RuntimeConfig {
683 listen: ListenConfig {
684 grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
685 http: SocketAddr::from(([127, 0, 0, 1], 8080)),
686 },
687 tls: None,
688 auth: AuthConfig {
689 enabled: false,
690 jwks_url: None,
691 jwks_refresh_seconds: 300,
692 },
693 dashboard: DashboardConfig {
694 source: DashboardAssetSource::Embedded,
695 },
696 namespace: NamespaceConfig {
697 mode: NamespaceMode::SharedEngine,
698 },
699 worker: WorkerConfig {
700 heartbeat_window: Duration::from_millis(30_000),
701 },
702 websocket: WebSocketConfig {
703 outbound_buffer_bound: 32,
704 event_broadcast_capacity: Some(64),
705 },
706 workflow_packages: Vec::new(),
707 deploy: DeployConfig::default(),
708 authoring: AuthoringConfig::default(),
709 dev: DevConfig::default(),
710 outbox: OutboxConfig::default(),
711 scheduler_threads: 1,
712 query_timeout: Some(Duration::from_millis(10_000)),
713 default_namespace: "default".to_owned(),
714 drain_timeout: Duration::from_secs(30),
715 metrics: MetricsConfig { enabled: true },
716 owned_shards: Vec::new(),
717 cors_allowed_origins: Vec::new(),
718 }
719 }
720
721 fn enabled_outbox_config() -> OutboxConfig {
724 OutboxConfig {
725 enabled: true,
726 poll_interval_ms: Some(250),
727 batch_size: Some(64),
728 max_attempts: Some(5),
729 backoff_base_ms: Some(100),
730 backoff_multiplier: Some(2),
731 backoff_max_ms: Some(30_000),
732 reconcile_interval_ms: None,
733 reconcile_stale_after_ms: None,
734 transport: OutboxTransport::Grpc,
735 liminal_listen_address: None,
736 }
737 }
738
739 #[tokio::test]
744 async fn outbox_enabled_on_memory_backend_is_a_config_error() {
745 let state = ServerState::build_with_store(InMemoryStore::default(), runtime_config())
746 .await
747 .expect("build in-memory state");
748 let (_tx, rx) = tokio::sync::watch::channel(false);
749 let error = maybe_spawn_outbox_dispatcher(&state, &enabled_outbox_config(), false, &rx)
750 .expect_err("outbox.enabled on the memory backend must be a config error");
751 assert!(
752 error.is_config(),
753 "memory-backend outbox error must be Config"
754 );
755 let message = error.to_string();
756 assert!(
757 message.contains("libsql") && message.contains("haematite"),
758 "corrected message must name both supported backends, got: {message}"
759 );
760 }
761
762 #[tokio::test]
766 async fn disabled_outbox_is_a_noop_on_any_backend() {
767 let state = ServerState::build_with_store(InMemoryStore::default(), runtime_config())
768 .await
769 .expect("build in-memory state");
770 let (_tx, rx) = tokio::sync::watch::channel(false);
771 maybe_spawn_outbox_dispatcher(&state, &OutboxConfig::default(), false, &rx)
772 .expect("disabled outbox gate must be an infallible no-op");
773 }
774
775 #[test]
778 fn reconciler_config_absent_unless_both_knobs_set() {
779 let mut config = enabled_outbox_config();
780 assert!(
782 resolve_outbox_reconciler_config(&config)
783 .expect("resolve")
784 .is_none()
785 );
786 config.reconcile_interval_ms = Some(1_000);
788 assert!(
789 resolve_outbox_reconciler_config(&config)
790 .expect("resolve")
791 .is_none()
792 );
793 config.reconcile_stale_after_ms = Some(60_000);
795 assert!(
796 resolve_outbox_reconciler_config(&config)
797 .expect("resolve")
798 .is_some()
799 );
800 }
801
802 #[cfg(feature = "liminal-transport")]
812 #[tokio::test]
813 async fn liminal_transport_requires_listen_address() {
814 use crate::config::{
815 RuntimeSection, ServerConfig, StoreBackend, StoreConfig, WebSocketConfig,
816 };
817
818 let db_path = std::env::temp_dir().join(format!(
819 "aion-lsub-prod-listen-guard-{}-{}.db",
820 std::process::id(),
821 std::time::SystemTime::now()
822 .duration_since(std::time::UNIX_EPOCH)
823 .map(|elapsed| elapsed.as_nanos())
824 .unwrap_or_default()
825 ));
826 let mut outbox = enabled_outbox_config();
827 outbox.transport = OutboxTransport::Liminal;
828 outbox.liminal_listen_address = None;
829 let config = ServerConfig {
830 store: StoreConfig {
831 backend: StoreBackend::LibSql,
832 url: Some(db_path.to_string_lossy().into_owned()),
833 ..StoreConfig::default()
834 },
835 runtime: RuntimeSection {
836 scheduler_threads: 1,
837 query_timeout_ms: Some(10_000),
838 },
839 websocket: WebSocketConfig {
840 outbound_buffer_bound: 32,
841 event_broadcast_capacity: Some(64),
842 },
843 outbox: outbox.clone(),
844 ..ServerConfig::default()
845 };
846 let state = ServerState::build(config)
847 .await
848 .expect("build libsql state");
849 let (_tx, rx) = tokio::sync::watch::channel(false);
850
851 let error = maybe_spawn_outbox_dispatcher(&state, &outbox, false, &rx)
852 .expect_err("liminal transport without a listen address must be a config error");
853 assert!(
854 error.is_config(),
855 "missing-listen-address error must be Config"
856 );
857 assert!(
858 error.to_string().contains("liminal_listen_address"),
859 "error must name the missing knob, got: {error}"
860 );
861 }
862}
863
864#[cfg(all(test, feature = "liminal-transport"))]
888mod lsub_prod_xnode_e2e {
889 #![allow(clippy::expect_used)]
890
891 use std::net::SocketAddr;
892 use std::path::PathBuf;
893 use std::sync::Arc;
894 use std::sync::atomic::{AtomicUsize, Ordering};
895 use std::time::{Duration, Instant};
896
897 use aion_core::Event;
898 use aion_package::{
899 BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest, ManifestVersion,
900 PackageBuilder,
901 };
902 use aion_store::ReadableEventStore;
903 use aion_store_libsql::LibSqlStore;
904 use aion_worker::{ActivityRegistry, LiminalActivityWorker, WorkerConfig};
905 use axum::body;
906 use axum::http::{Request, StatusCode};
907 use serde_json::json;
908 use tower::ServiceExt;
909
910 use super::maybe_spawn_outbox_dispatcher;
911 use crate::ServerState;
912 use crate::api::http::http_router;
913 use crate::config::{
914 OutboxConfig, OutboxTransport, RuntimeSection, ServerConfig, StoreBackend, StoreConfig,
915 WebSocketConfig,
916 };
917
918 type TestError = Box<dyn std::error::Error + Send + Sync>;
919
920 type FanInput = String;
923
924 const NAMESPACE: &str = "default";
925 const TASK_QUEUE: &str = "default";
926 const OUTBOX_MODULE: &str = "aion_outbox_fixture";
927 const OUTBOX_BEAM: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.beam");
928 const OUTBOX_SOURCE: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.erl");
929 const FAN_OUT: usize = 4;
930 const FAN_ACTIVITY_TYPES: [&str; FAN_OUT] = ["fan:0", "fan:1", "fan:2", "fan:3"];
931 const POLL_DEADLINE: Duration = Duration::from_secs(20);
932
933 fn test_error(message: impl std::fmt::Display) -> TestError {
934 message.to_string().into()
935 }
936
937 fn reserve_loopback_port() -> Result<SocketAddr, TestError> {
941 let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(test_error)?;
942 let address = listener.local_addr().map_err(test_error)?;
943 drop(listener);
944 Ok(address)
945 }
946
947 fn write_package_archive(dir: &std::path::Path) -> Result<PathBuf, TestError> {
950 let beams =
951 BeamSet::new(vec![BeamModule::new(OUTBOX_MODULE, OUTBOX_BEAM)]).map_err(test_error)?;
952 let manifest = Manifest {
953 entry_module: OUTBOX_MODULE.to_owned(),
954 entry_function: "collect_four".to_owned(),
955 input_schema: json!({ "type": "object" }),
956 output_schema: json!({}),
957 timeout: Duration::from_secs(30),
958 activities: vec![DeclaredActivity {
959 activity_type: "fixture_activity".to_owned(),
960 }],
961 version: ManifestVersion::new("stamped-by-builder"),
962 format_version: CURRENT_FORMAT_VERSION,
963 };
964 let archive =
965 PackageBuilder::with_source(manifest, beams, [(OUTBOX_MODULE, OUTBOX_SOURCE.to_vec())])
966 .write_to_bytes()
967 .map_err(test_error)?;
968 let path = dir.join("collect_four.aion");
969 std::fs::write(&path, archive).map_err(test_error)?;
970 Ok(path)
971 }
972
973 fn server_config(
980 db_path: &std::path::Path,
981 package_path: PathBuf,
982 listen_address: SocketAddr,
983 ) -> ServerConfig {
984 ServerConfig {
985 store: StoreConfig {
986 backend: StoreBackend::LibSql,
987 url: Some(db_path.to_string_lossy().into_owned()),
988 ..StoreConfig::default()
989 },
990 runtime: RuntimeSection {
991 scheduler_threads: 1,
992 query_timeout_ms: Some(10_000),
993 },
994 websocket: WebSocketConfig {
995 outbound_buffer_bound: 32,
996 event_broadcast_capacity: Some(64),
997 },
998 workflow_packages: vec![package_path],
999 outbox: OutboxConfig {
1000 enabled: true,
1001 poll_interval_ms: Some(20),
1002 batch_size: Some(16),
1003 max_attempts: Some(5),
1004 backoff_base_ms: Some(50),
1005 backoff_multiplier: Some(2),
1006 backoff_max_ms: Some(1_000),
1007 reconcile_interval_ms: None,
1008 reconcile_stale_after_ms: None,
1009 transport: OutboxTransport::Liminal,
1010 liminal_listen_address: Some(listen_address.to_string()),
1011 },
1012 ..ServerConfig::default()
1013 }
1014 }
1015
1016 fn worker_config() -> Result<WorkerConfig, TestError> {
1020 WorkerConfig::builder()
1021 .endpoint("unused-direct-address")
1022 .namespace(NAMESPACE)
1023 .task_queue(TASK_QUEUE)
1024 .identity("lsub-prod-worker")
1025 .max_concurrency(4)
1026 .reconnect_initial_backoff(Duration::from_millis(5))
1027 .reconnect_max_backoff(Duration::from_millis(20))
1028 .reconnect_max_attempts(3)
1029 .build()
1030 .map_err(test_error)
1031 }
1032
1033 fn worker_registry(executions: &Arc<AtomicUsize>) -> Result<Arc<ActivityRegistry>, TestError> {
1034 let mut registry = ActivityRegistry::new();
1035 for activity_type in FAN_ACTIVITY_TYPES {
1036 let executions = Arc::clone(executions);
1037 registry = registry
1038 .register_activity(activity_type, move |_input: FanInput, _context| {
1039 let executions = Arc::clone(&executions);
1040 Box::pin(async move {
1041 executions.fetch_add(1, Ordering::SeqCst);
1042 Ok(activity_type.to_owned())
1043 })
1044 })
1045 .map_err(test_error)?;
1046 }
1047 Ok(Arc::new(registry))
1048 }
1049
1050 struct WorkerThread {
1053 stop: Arc<std::sync::atomic::AtomicBool>,
1054 handle: Option<std::thread::JoinHandle<()>>,
1055 }
1056
1057 impl WorkerThread {
1058 fn spawn(address: String, config: WorkerConfig, registry: Arc<ActivityRegistry>) -> Self {
1059 let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1060 let thread_stop = Arc::clone(&stop);
1061 let handle = std::thread::spawn(move || {
1062 let runtime = match tokio::runtime::Builder::new_current_thread()
1063 .enable_all()
1064 .build()
1065 {
1066 Ok(runtime) => runtime,
1067 Err(error) => {
1068 eprintln!("worker runtime build failed: {error}");
1069 return;
1070 }
1071 };
1072 runtime.block_on(async move {
1073 let worker = match LiminalActivityWorker::connect(&address, &config, registry) {
1074 Ok(worker) => worker,
1075 Err(error) => {
1076 eprintln!("worker connect failed: {error}");
1077 return;
1078 }
1079 };
1080 if let Err(error) = worker
1081 .serve_until(|| thread_stop.load(Ordering::SeqCst))
1082 .await
1083 {
1084 eprintln!("worker serve loop ended with error: {error}");
1085 }
1086 });
1087 });
1088 Self {
1089 stop,
1090 handle: Some(handle),
1091 }
1092 }
1093
1094 fn stop(mut self) {
1095 self.stop.store(true, Ordering::SeqCst);
1096 if let Some(handle) = self.handle.take() {
1097 handle.join().ok();
1098 }
1099 }
1100 }
1101
1102 fn count_completed(history: &[Event]) -> usize {
1103 history
1104 .iter()
1105 .filter(|event| matches!(event, Event::ActivityCompleted { .. }))
1106 .count()
1107 }
1108
1109 fn count_workflow_completed(history: &[Event]) -> usize {
1110 history
1111 .iter()
1112 .filter(|event| matches!(event, Event::WorkflowCompleted { .. }))
1113 .count()
1114 }
1115
1116 async fn wait_for_history<F>(
1117 store: &LibSqlStore,
1118 workflow_id: &aion_core::WorkflowId,
1119 description: &str,
1120 predicate: F,
1121 ) -> Result<Vec<Event>, TestError>
1122 where
1123 F: Fn(&[Event]) -> bool,
1124 {
1125 let deadline = Instant::now() + POLL_DEADLINE;
1126 loop {
1127 let history = store.read_history(workflow_id).await.map_err(test_error)?;
1128 if predicate(&history) {
1129 return Ok(history);
1130 }
1131 if Instant::now() > deadline {
1132 return Err(test_error(format!(
1133 "timed out waiting for {description}: {history:#?}"
1134 )));
1135 }
1136 tokio::time::sleep(Duration::from_millis(25)).await;
1137 }
1138 }
1139
1140 async fn start_over_http(router: &axum::Router) -> Result<aion_core::WorkflowId, TestError> {
1142 let build_request = || -> Result<Request<body::Body>, TestError> {
1143 Request::builder()
1144 .uri("/workflows/start")
1145 .method("POST")
1146 .header("content-type", "application/json")
1147 .header("x-aion-subject", "ci")
1148 .header("x-aion-namespaces", NAMESPACE)
1149 .body(body::Body::from(
1150 serde_json::to_vec(&json!({
1151 "namespace": NAMESPACE,
1152 "workflow_type": OUTBOX_MODULE,
1153 "input": { "fixture": "input" },
1154 }))
1155 .map_err(test_error)?,
1156 ))
1157 .map_err(test_error)
1158 };
1159 let response = router
1160 .clone()
1161 .oneshot(build_request()?)
1162 .await
1163 .map_err(test_error)?;
1164 let status = response.status();
1165 let bytes = body::to_bytes(response.into_body(), usize::MAX)
1166 .await
1167 .map_err(test_error)?
1168 .to_vec();
1169 if status != StatusCode::OK {
1170 return Err(test_error(format!(
1171 "workflow start over HTTP must succeed, got {status}: {}",
1172 String::from_utf8_lossy(&bytes)
1173 )));
1174 }
1175 let body: serde_json::Value = serde_json::from_slice(&bytes).map_err(test_error)?;
1176 let workflow_id = body["workflow_id"]["uuid"]
1177 .as_str()
1178 .ok_or_else(|| test_error("start response missing workflow id"))?
1179 .parse::<uuid::Uuid>()
1180 .map_err(test_error)?;
1181 Ok(aion_core::WorkflowId::new(workflow_id))
1182 }
1183
1184 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1185 async fn production_boot_dispatches_executes_and_records_over_liminal() -> Result<(), TestError>
1186 {
1187 let dir = tempfile::tempdir().map_err(test_error)?;
1188 let db_path = dir.path().join("aion.db");
1189 let package_path = write_package_archive(dir.path())?;
1190 let listen_address = reserve_loopback_port()?;
1193
1194 let config = server_config(&db_path, package_path, listen_address);
1201 let outbox_config = config.outbox.clone();
1202 let state = ServerState::build(config).await.map_err(test_error)?;
1203
1204 let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
1210 let listener_guard =
1211 maybe_spawn_outbox_dispatcher(&state, &outbox_config, false, &shutdown_rx)
1212 .map_err(test_error)?;
1213
1214 let executions = Arc::new(AtomicUsize::new(0));
1217 let worker = WorkerThread::spawn(
1218 listen_address.to_string(),
1219 worker_config()?,
1220 worker_registry(&executions)?,
1221 );
1222
1223 let registry = state.worker_registry().clone();
1226 let deadline = Instant::now() + Duration::from_secs(5);
1227 loop {
1228 let ready = FAN_ACTIVITY_TYPES.iter().all(|activity_type| {
1229 registry
1230 .select_worker(NAMESPACE, TASK_QUEUE, activity_type, None)
1231 .ok()
1232 .flatten()
1233 .is_some()
1234 });
1235 if ready {
1236 break;
1237 }
1238 if Instant::now() > deadline {
1239 worker.stop();
1240 return Err(test_error("worker never registered in-band for the pool"));
1241 }
1242 tokio::time::sleep(Duration::from_millis(10)).await;
1243 }
1244
1245 let router = http_router(state.clone()).map_err(test_error)?;
1249 let workflow_id = start_over_http(&router).await?;
1250
1251 let reader = LibSqlStore::open(db_path.clone())
1256 .await
1257 .map_err(test_error)?;
1258 let settled = wait_for_history(&reader, &workflow_id, "fan-out settled", |events| {
1259 count_completed(events) == FAN_OUT && count_workflow_completed(events) == 1
1260 })
1261 .await?;
1262 assert_eq!(
1263 count_completed(&settled),
1264 FAN_OUT,
1265 "every fan-out member must record a terminal through the production callback"
1266 );
1267 assert_eq!(
1268 count_workflow_completed(&settled),
1269 1,
1270 "the workflow must complete exactly once"
1271 );
1272 assert_eq!(
1273 executions.load(Ordering::SeqCst),
1274 FAN_OUT,
1275 "the remote worker must have executed every pushed dispatch exactly once"
1276 );
1277
1278 shutdown_tx.send(true).ok();
1281 worker.stop();
1282 drop(listener_guard);
1283 state.shutdown().map_err(test_error)?;
1284 Ok(())
1285 }
1286}