use std::{net::SocketAddr, process::ExitCode};
use tokio::net::TcpListener;
use tonic::transport::Server as TonicServer;
use tracing::{error, info, warn};
use std::sync::Arc;
use crate::{
ServerConfig, ServerError, ServerState, api,
config::{CliOverrides, NamespaceMode, OutboxConfig, OutboxTransport, StoreBackend},
observability,
shutdown::{self, ShutdownOutcome},
worker::{
ActivityDispatcher, DeliveryGate, OutboxDeliveryCallback, OutboxDispatcher,
OutboxDispatcherConfig, OutboxReconciler, OutboxReconcilerConfig, OutboxRowDispatch,
ServerOutboxDeliveryCallback, WorkerOutboxDispatch,
},
};
const PLACEMENT_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
const QUOTA_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
const QUOTA_BROADCAST_CADENCE: std::time::Duration = std::time::Duration::from_secs(1);
#[derive(Clone, Copy, Debug)]
struct BackpressureSettings {
platform_default: u32,
fraction: crate::worker::OwnedShardFraction,
}
impl BackpressureSettings {
fn from_config(config: &ServerConfig) -> Self {
let total = u32::try_from(config.store.shard_count).unwrap_or(u32::MAX);
let fraction = if config.store.owned_shards.is_empty() {
crate::worker::OwnedShardFraction::own_all()
} else {
let owned = u32::try_from(config.store.owned_shards.len()).unwrap_or(u32::MAX);
crate::worker::OwnedShardFraction::new(owned, total)
};
Self {
platform_default: config.namespaces.max_in_flight_activities,
fraction,
}
}
}
#[derive(Debug, Default)]
struct OutboxWorkerListener {
#[cfg(feature = "liminal-transport")]
_inner: Option<liminal_server::server::listener::ServerListener>,
}
pub async fn run(overrides: CliOverrides) -> ExitCode {
match run_server(overrides).await {
Ok(code) => code,
Err(error) => {
error!(%error, "aion-server failed");
if error.is_config() {
ExitCode::from(2)
} else {
ExitCode::FAILURE
}
}
}
}
async fn run_server(cli: CliOverrides) -> Result<ExitCode, ServerError> {
observability::tracing::init()?;
let loaded = ServerConfig::load_resolved(&cli)?;
loaded.resolution.ensure_private_home()?;
loaded.resolution.log_startup();
let config = loaded.config;
reject_auth_without_feature(&config)?;
let store_backend = config.store.backend;
let owned_shards = config.store.owned_shards.clone();
let outbox_config = config.outbox.clone();
let backpressure_settings = BackpressureSettings::from_config(&config);
#[cfg(feature = "haematite-backend")]
let cluster_config = config.store.cluster.clone();
let state = ServerState::build(config).await?;
reject_tls_until_supported(&state)?;
let runtime = state.runtime_config();
let grpc_address = runtime.listen.grpc;
let http_address = runtime.listen.http;
let workflow_packages: Vec<String> = runtime
.workflow_packages
.iter()
.map(|path| path.display().to_string())
.collect();
let build = crate::build_identity::BuildIdentity::current();
let workspace_root = state.workspace_root().banner_value();
info!(
version = env!("CARGO_PKG_VERSION"),
build = %build.line(),
commit = build.commit,
grpc_address = %grpc_address,
http_address = %http_address,
default_namespace = %runtime.default_namespace,
namespace_mode = namespace_mode_label(&runtime.namespace.mode),
store_backend = store_backend_label(store_backend),
auth_enabled = runtime.auth.enabled,
deploy_enabled = runtime.deploy.enabled,
metrics_enabled = runtime.metrics.enabled,
workspace_root = %workspace_root,
workflow_package_count = workflow_packages.len(),
workflow_packages = ?workflow_packages,
owned_shards = ?owned_shards,
owns_all_shards = owned_shards.is_empty(),
"aion-server startup banner"
);
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
#[cfg(feature = "haematite-backend")]
let outbox_clustered = cluster_config.is_some();
#[cfg(not(feature = "haematite-backend"))]
let outbox_clustered = false;
rebuild_outbox_boot_state(&state, &outbox_config).await;
let _outbox_worker_listener = maybe_spawn_outbox_dispatcher(
&state,
&outbox_config,
outbox_clustered,
backpressure_settings,
&shutdown_rx,
)?;
#[cfg(feature = "haematite-backend")]
maybe_spawn_cluster_supervisor(&state, cluster_config.as_ref(), &shutdown_rx)?;
drop(state.spawn_heartbeat_sweeper(shutdown_rx.clone()));
let mut grpc = tokio::spawn(serve_grpc(state.clone(), grpc_address, shutdown_rx.clone()));
let mut http = tokio::spawn(serve_http(state.clone(), http_address, shutdown_rx));
let outcome = tokio::select! {
result = &mut grpc => {
transport_result("gRPC", result)?;
state.shutdown()?;
ShutdownOutcome::Clean
},
result = &mut http => {
transport_result("HTTP", result)?;
state.shutdown()?;
ShutdownOutcome::Clean
},
result = shutdown_signal() => {
result?;
let _receiver_count = shutdown_tx.send(true);
let outcome = shutdown::drain_after_first_signal(state.clone(), async {
let _ = shutdown_signal().await;
}).await?;
if !matches!(outcome, ShutdownOutcome::Forced) {
transport_result("gRPC", grpc.await)?;
transport_result("HTTP", http.await)?;
}
outcome
},
};
Ok(outcome.exit_code())
}
fn transport_result(
transport: &'static str,
result: Result<Result<(), ServerError>, tokio::task::JoinError>,
) -> Result<(), ServerError> {
match result {
Ok(transport_outcome) => transport_outcome,
Err(join_error) => Err(ServerError::Transport {
transport,
message: join_error.to_string(),
}),
}
}
async fn serve_grpc(
state: ServerState,
address: SocketAddr,
shutdown: tokio::sync::watch::Receiver<bool>,
) -> Result<(), ServerError> {
let workflow = api::grpc::workflow_service(state.clone());
let worker = api::worker_grpc::worker_service(state.clone());
let mut router = TonicServer::builder()
.add_service(workflow)
.add_service(worker);
if state.runtime_config().deploy.enabled {
router = router.add_service(api::deploy_grpc::deploy_service(state)?);
}
router
.serve_with_shutdown(address, shutdown_requested(shutdown))
.await
.map_err(|source| transport_bind("grpc", address, source))?;
Ok(())
}
async fn serve_http(
state: ServerState,
address: SocketAddr,
shutdown: tokio::sync::watch::Receiver<bool>,
) -> Result<(), ServerError> {
let listener = TcpListener::bind(address)
.await
.map_err(|source| transport_bind("http", address, source))?;
axum::serve(listener, api::http::http_router(state)?)
.with_graceful_shutdown(shutdown_requested(shutdown))
.await
.map_err(|source| transport_bind("http", address, source))?;
Ok(())
}
async fn shutdown_requested(mut shutdown: tokio::sync::watch::Receiver<bool>) {
while !*shutdown.borrow_and_update() {
if shutdown.changed().await.is_err() {
break;
}
}
}
async fn shutdown_signal() -> Result<(), ServerError> {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let mut terminate = signal(SignalKind::terminate())
.map_err(|source| signal_listener("SIGTERM", &source))?;
let mut interrupt =
signal(SignalKind::interrupt()).map_err(|source| signal_listener("SIGINT", &source))?;
tokio::select! {
_ = terminate.recv() => Ok(()),
_ = interrupt.recv() => Ok(()),
}
}
#[cfg(not(unix))]
{
tokio::signal::ctrl_c()
.await
.map_err(|source| signal_listener("shutdown signal", &source))
}
}
fn signal_listener(listener: &'static str, source: &std::io::Error) -> ServerError {
ServerError::SignalListener {
listener,
message: source.to_string(),
}
}
fn reject_auth_without_feature(config: &ServerConfig) -> Result<(), ServerError> {
if cfg!(not(feature = "auth")) && config.auth.enabled {
return Err(ServerError::Config {
message: "auth.enabled=true but binary compiled without auth feature".to_owned(),
});
}
Ok(())
}
async fn rebuild_outbox_boot_state(state: &ServerState, outbox_config: &OutboxConfig) {
if !outbox_config.enabled {
return;
}
let Ok(engine) = state.engine() else {
return;
};
if let Err(error) = engine.rebuild_paused_runs().await {
warn!(%error, "failed to rebuild paused-runs dispatch hold at startup");
}
let Some(outbox_store) = state.outbox_store() else {
return;
};
match crate::worker::settle_terminal_outbox_rows(engine.store().as_ref(), outbox_store.as_ref())
.await
{
Ok(settled) if settled.is_empty() => {}
Ok(settled) => {
info!(
settled = settled.len(),
"boot sweep settled stranded outbox rows for terminal workflows"
);
}
Err(error) => {
error!(
%error,
"boot sweep failed to settle terminal workflows' outbox rows; \
the reconciler liveness gate remains the backstop"
);
}
}
}
fn maybe_spawn_outbox_dispatcher(
state: &ServerState,
outbox_config: &OutboxConfig,
clustered: bool,
backpressure_settings: BackpressureSettings,
shutdown_rx: &tokio::sync::watch::Receiver<bool>,
) -> Result<OutboxWorkerListener, ServerError> {
if !outbox_config.enabled {
return Ok(OutboxWorkerListener::default());
}
let dispatcher_config = resolve_outbox_config(outbox_config)?;
let outbox_store = state.outbox_store().ok_or_else(|| ServerError::Config {
message: "outbox.enabled=true requires store.backend=libsql or store.backend=haematite: \
the durable outbox dispatcher claims rows from the store's outbox table, which \
the in-memory store does not provide"
.to_owned(),
})?;
let dispatcher_builder = OutboxDispatcher::new(Arc::clone(&outbox_store), dispatcher_config);
let delivery_gate = dispatcher_builder.delivery_gate();
let engine = state.engine()?;
let delivery_callback: Arc<dyn OutboxDeliveryCallback> =
Arc::new(ServerOutboxDeliveryCallback::new(engine));
let (row_dispatch, worker_listener) = select_outbox_row_dispatch(
state,
outbox_config,
shutdown_rx,
delivery_gate.clone(),
Arc::clone(&delivery_callback),
)?;
let quota_cache = crate::worker::QuotaCache::new(
Arc::clone(state.namespace_store()),
backpressure_settings.platform_default,
QUOTA_CACHE_TTL,
);
let backpressure =
crate::worker::Backpressure::new(quota_cache.clone(), backpressure_settings.fraction);
let mut dispatcher = dispatcher_builder
.with_dispatch(row_dispatch)
.with_delivery_callback(delivery_callback)
.with_wake(state.outbox_wake())
.with_backpressure(backpressure);
if let Ok(engine) = state.engine() {
dispatcher = dispatcher.with_paused_runs(engine.paused_runs());
}
tokio::spawn(dispatcher.run(shutdown_rx.clone()));
let quota_broadcaster = crate::worker::QuotaBroadcaster::new(
Arc::clone(state.namespace_store()),
Arc::clone(&outbox_store),
quota_cache,
state.cluster_publisher().clone(),
QUOTA_BROADCAST_CADENCE,
);
tokio::spawn(quota_broadcaster.run(shutdown_rx.clone()));
info!(
clustered,
"outbox dispatcher commissioned (active-active per-shard ownership enforced by claim scope \
when clustered; single-node owns all shards)"
);
if let Some(reconciler_config) = resolve_outbox_reconciler_config(outbox_config)? {
let event_store = state.engine()?.store();
let reconciler = OutboxReconciler::new(outbox_store, event_store, reconciler_config)
.with_delivery_gate(delivery_gate);
tokio::spawn(reconciler.run(shutdown_rx.clone()));
info!("outbox reconciler commissioned (terminal-workflow liveness gate active)");
} else if clustered {
warn!(
"outbox reconciler is UNCONFIGURED on a clustered boot (outbox.reconcile_interval_ms \
and outbox.reconcile_stale_after_ms are both unset): in-flight recovery after an \
owner is killed is then bounded only by re-residency replay on the adopting node, \
not by a stale-claim backstop; set both knobs to bound stale-claim recovery latency"
);
}
Ok(worker_listener)
}
#[cfg(feature = "haematite-backend")]
fn maybe_spawn_cluster_supervisor(
state: &ServerState,
cluster_config: Option<&crate::config::ClusterConfig>,
shutdown_rx: &tokio::sync::watch::Receiver<bool>,
) -> Result<(), ServerError> {
let Some(cluster) = cluster_config else {
return Ok(());
};
let poll_interval = std::time::Duration::from_millis(
cluster
.failover_poll_interval_ms
.unwrap_or(crate::config::DEFAULT_FAILOVER_POLL_INTERVAL_MS),
);
let confirmations = cluster
.failover_confirmations
.unwrap_or(crate::config::DEFAULT_FAILOVER_CONFIRMATIONS);
let supervisor_config = crate::cluster::SupervisorConfig {
poll_interval,
confirmations,
};
let spawned = state.spawn_cluster_supervisor(supervisor_config, shutdown_rx.clone())?;
if spawned {
info!(
poll_interval_ms = %poll_interval.as_millis(),
confirmations,
"SS-5b cluster supervisor commissioned (automatic peer-down failover)"
);
}
Ok(())
}
fn select_outbox_row_dispatch(
state: &ServerState,
outbox_config: &OutboxConfig,
shutdown_rx: &tokio::sync::watch::Receiver<bool>,
delivery_gate: DeliveryGate,
delivery_callback: Arc<dyn OutboxDeliveryCallback>,
) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
match outbox_config.transport {
OutboxTransport::Grpc => {
let push_dispatcher = ActivityDispatcher::new(state.worker_registry().clone())
.with_drain_state(state.drain_state().clone())
.with_completion_fences(state.pending_activities().completion_fences())
.with_queue_service(
state.queue_declarations().clone(),
state.queue_service_state().clone(),
state.runtime_config().worker.queue_service.clone(),
);
let placement_cache = crate::worker::PlacementCache::new(
Arc::clone(state.namespace_store()),
PLACEMENT_CACHE_TTL,
);
let dispatch: Arc<dyn OutboxRowDispatch> = Arc::new(
WorkerOutboxDispatch::new(push_dispatcher).with_placement_cache(placement_cache),
);
Ok((dispatch, OutboxWorkerListener::default()))
}
OutboxTransport::Liminal => build_liminal_row_dispatch(
state,
outbox_config,
shutdown_rx,
delivery_gate,
delivery_callback,
),
}
}
#[cfg(feature = "liminal-transport")]
fn build_liminal_row_dispatch(
state: &ServerState,
outbox_config: &OutboxConfig,
shutdown_rx: &tokio::sync::watch::Receiver<bool>,
delivery_gate: DeliveryGate,
callback: Arc<dyn OutboxDeliveryCallback>,
) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
use liminal_server::config::ServerConfig as LiminalServerConfig;
use liminal_server::config::{LimitsConfig, ServicesConfig};
use liminal_server::server::connection::{ConnectionSupervisor, LiminalConnectionServices};
use liminal_server::server::listener::ServerListener;
use crate::worker::{LiminalConnectionNotifier, RegistryLiminalDispatch};
let listen_address = outbox_config
.liminal_listen_address
.as_ref()
.ok_or_else(|| ServerError::Config {
message: "outbox.transport=liminal requires outbox.liminal_listen_address \
(host:port the aion-server listens on for inbound liminal worker \
connections)"
.to_owned(),
})?;
let listen_address: SocketAddr =
listen_address
.parse()
.map_err(|error| ServerError::Config {
message: format!(
"outbox.liminal_listen_address must be a host:port socket address: {error}"
),
})?;
let liminal_config = LiminalServerConfig {
listen_address,
health_listen_address: listen_address,
drain_timeout_ms: 30_000,
channels: Vec::new(),
routing_rules: Vec::new(),
persistence_path: None,
cluster: None,
auth: None,
services: ServicesConfig::default(),
limits: LimitsConfig::default(),
websocket: None,
participant: None,
};
let registry = state.worker_registry().clone();
let notifier = Arc::new(
LiminalConnectionNotifier::new(registry.clone())
.with_contract_catalog(state.engine()?)
.with_transcript_publisher(state.transcript_publisher().clone())
.with_heartbeat_tracker(state.heartbeat_tracker().clone()),
);
let services = Arc::new(
LiminalConnectionServices::from_config(&liminal_config).map_err(|error| {
ServerError::Config {
message: format!("liminal connection services build failed: {error}"),
}
})?,
);
let supervisor = ConnectionSupervisor::with_services_and_notifier(services, notifier.clone())
.map_err(|error| ServerError::Config {
message: format!("liminal connection supervisor build failed: {error}"),
})?;
if !notifier.bind_supervisor(supervisor.clone()) {
return Err(ServerError::Config {
message: "liminal notifier supervisor handle was already bound during boot".to_owned(),
});
}
drop(state.spawn_liminal_liveness_probe(notifier.clone(), shutdown_rx.clone()));
let listener =
ServerListener::bind(&liminal_config, supervisor).map_err(|error| ServerError::Config {
message: format!("liminal worker listener failed to bind {listen_address}: {error}"),
})?;
let placement_cache = crate::worker::PlacementCache::new(
Arc::clone(state.namespace_store()),
PLACEMENT_CACHE_TTL,
);
let dispatch: Arc<dyn OutboxRowDispatch> = Arc::new(
RegistryLiminalDispatch::new(registry, callback, delivery_gate)
.with_placement_cache(placement_cache)
.with_attempt_owners(state.attempt_owners().clone()),
);
info!(
listen_address = %listen_address,
"liminal outbox worker listener commissioned (remote workers connect in and self-register)"
);
Ok((
dispatch,
OutboxWorkerListener {
_inner: Some(listener),
},
))
}
#[cfg(not(feature = "liminal-transport"))]
fn build_liminal_row_dispatch(
_state: &ServerState,
_outbox_config: &OutboxConfig,
_shutdown_rx: &tokio::sync::watch::Receiver<bool>,
_delivery_gate: DeliveryGate,
_delivery_callback: Arc<dyn OutboxDeliveryCallback>,
) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
Err(ServerError::Config {
message: "outbox.transport=liminal requires the aion-server `liminal-transport` \
Cargo feature, which is not enabled in this build"
.to_owned(),
})
}
fn resolve_outbox_config(outbox: &OutboxConfig) -> Result<OutboxDispatcherConfig, ServerError> {
let poll_interval_ms = outbox.poll_interval_ms.ok_or_else(|| ServerError::Config {
message: crate::config::OUTBOX_POLL_INTERVAL_REQUIRED.to_owned(),
})?;
let batch_size = outbox.batch_size.ok_or_else(|| ServerError::Config {
message: crate::config::OUTBOX_BATCH_SIZE_REQUIRED.to_owned(),
})?;
let max_attempts = outbox.max_attempts.ok_or_else(|| ServerError::Config {
message: crate::config::OUTBOX_MAX_ATTEMPTS_REQUIRED.to_owned(),
})?;
let backoff_base_ms = outbox.backoff_base_ms.ok_or_else(|| ServerError::Config {
message: crate::config::OUTBOX_BACKOFF_BASE_REQUIRED.to_owned(),
})?;
let backoff_multiplier = outbox
.backoff_multiplier
.ok_or_else(|| ServerError::Config {
message: crate::config::OUTBOX_BACKOFF_MULTIPLIER_REQUIRED.to_owned(),
})?;
let backoff_max_ms = outbox.backoff_max_ms.ok_or_else(|| ServerError::Config {
message: crate::config::OUTBOX_BACKOFF_MAX_REQUIRED.to_owned(),
})?;
Ok(OutboxDispatcherConfig {
poll_interval: std::time::Duration::from_millis(poll_interval_ms),
batch_size,
max_attempts,
backoff_base: std::time::Duration::from_millis(backoff_base_ms),
backoff_multiplier,
backoff_max: std::time::Duration::from_millis(backoff_max_ms),
})
}
fn resolve_outbox_reconciler_config(
outbox: &OutboxConfig,
) -> Result<Option<OutboxReconcilerConfig>, ServerError> {
let (Some(interval_ms), Some(stale_after_ms)) = (
outbox.reconcile_interval_ms,
outbox.reconcile_stale_after_ms,
) else {
return Ok(None);
};
let batch_size = outbox.batch_size.ok_or_else(|| ServerError::Config {
message: crate::config::OUTBOX_BATCH_SIZE_REQUIRED.to_owned(),
})?;
Ok(Some(OutboxReconcilerConfig {
interval: std::time::Duration::from_millis(interval_ms),
stale_after: std::time::Duration::from_millis(stale_after_ms),
batch_size,
}))
}
fn reject_tls_until_supported(state: &ServerState) -> Result<(), ServerError> {
if state.runtime_config().tls.is_some() {
return Err(ServerError::Config {
message: "configured TLS material cannot be served until transport TLS is wired"
.to_owned(),
});
}
Ok(())
}
fn store_backend_label(backend: StoreBackend) -> &'static str {
match backend {
StoreBackend::Memory => "memory",
StoreBackend::LibSql => "libsql",
StoreBackend::Haematite => "haematite",
}
}
fn namespace_mode_label(mode: &NamespaceMode) -> &'static str {
match mode {
NamespaceMode::SharedEngine => "SharedEngine",
NamespaceMode::SingleTenant { .. } => "SingleTenant",
}
}
fn transport_bind<E>(transport: &'static str, address: SocketAddr, source: E) -> ServerError
where
E: std::error::Error,
{
ServerError::TransportBind {
transport,
address,
message: source.to_string(),
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::{
BackpressureSettings, OutboxConfig, OutboxTransport, maybe_spawn_outbox_dispatcher,
resolve_outbox_reconciler_config,
};
use crate::ServerState;
use crate::config::RuntimeConfig;
use aion_store::InMemoryStore;
use std::net::SocketAddr;
use std::time::Duration;
fn test_backpressure_settings() -> BackpressureSettings {
BackpressureSettings {
platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
fraction: crate::worker::OwnedShardFraction::own_all(),
}
}
fn runtime_config() -> RuntimeConfig {
use crate::config::{
AuthConfig, AuthoringConfig, DeployConfig, DevConfig, ListenConfig, MetricsConfig,
NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig,
WebSocketConfig, WorkerConfig,
};
RuntimeConfig {
listen: ListenConfig {
grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
http: SocketAddr::from(([127, 0, 0, 1], 8080)),
},
tls: None,
auth: AuthConfig {
enabled: false,
jwks_url: None,
jwks_refresh_seconds: 300,
},
ops_console: OpsConsoleConfig {
source: OpsConsoleAssetSource::Embedded,
},
namespace: NamespaceConfig {
mode: NamespaceMode::SharedEngine,
},
worker: WorkerConfig {
heartbeat_window: Duration::from_secs(30),
..WorkerConfig::default()
},
websocket: WebSocketConfig {
outbound_buffer_bound: 32,
event_broadcast_capacity: Some(64),
cluster_broadcast_capacity: Some(64),
},
workflow_packages: Vec::new(),
deploy: DeployConfig::default(),
authoring: AuthoringConfig::default(),
dev: DevConfig::default(),
outbox: OutboxConfig::default(),
observability: crate::config::ObservabilityConfig::default(),
scheduler_threads: 1,
query_timeout: Some(Duration::from_secs(10)),
default_namespace: "default".to_owned(),
auto_create: crate::config::AutoCreate::Open,
max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
drain_timeout: Duration::from_secs(30),
metrics: MetricsConfig { enabled: true },
owned_shards: Vec::new(),
cors_allowed_origins: Vec::new(),
}
}
fn enabled_outbox_config() -> OutboxConfig {
OutboxConfig {
enabled: true,
poll_interval_ms: Some(250),
batch_size: Some(64),
max_attempts: Some(5),
backoff_base_ms: Some(100),
backoff_multiplier: Some(2),
backoff_max_ms: Some(30_000),
reconcile_interval_ms: None,
reconcile_stale_after_ms: None,
transport: OutboxTransport::Grpc,
liminal_listen_address: None,
}
}
#[tokio::test]
async fn outbox_enabled_on_memory_backend_is_a_config_error() {
let state = ServerState::build_with_store(InMemoryStore::default(), runtime_config())
.await
.expect("build in-memory state");
let (_tx, rx) = tokio::sync::watch::channel(false);
let error = maybe_spawn_outbox_dispatcher(
&state,
&enabled_outbox_config(),
false,
test_backpressure_settings(),
&rx,
)
.expect_err("outbox.enabled on the memory backend must be a config error");
assert!(
error.is_config(),
"memory-backend outbox error must be Config"
);
let message = error.to_string();
assert!(
message.contains("libsql") && message.contains("haematite"),
"corrected message must name both supported backends, got: {message}"
);
}
#[tokio::test]
async fn disabled_outbox_is_a_noop_on_any_backend() {
let state = ServerState::build_with_store(InMemoryStore::default(), runtime_config())
.await
.expect("build in-memory state");
let (_tx, rx) = tokio::sync::watch::channel(false);
maybe_spawn_outbox_dispatcher(
&state,
&OutboxConfig::default(),
false,
test_backpressure_settings(),
&rx,
)
.expect("disabled outbox gate must be an infallible no-op");
}
#[test]
fn reconciler_config_absent_unless_both_knobs_set() {
let mut config = enabled_outbox_config();
assert!(
resolve_outbox_reconciler_config(&config)
.expect("resolve")
.is_none()
);
config.reconcile_interval_ms = Some(1_000);
assert!(
resolve_outbox_reconciler_config(&config)
.expect("resolve")
.is_none()
);
config.reconcile_stale_after_ms = Some(60_000);
assert!(
resolve_outbox_reconciler_config(&config)
.expect("resolve")
.is_some()
);
}
#[cfg(all(feature = "liminal-transport", feature = "libsql-backend"))]
#[tokio::test]
async fn liminal_transport_requires_listen_address() {
use crate::config::{
RuntimeSection, ServerConfig, StoreBackend, StoreConfig, WebSocketConfig,
};
let db_path = std::env::temp_dir().join(format!(
"aion-lsub-prod-listen-guard-{}-{}.db",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_nanos())
.unwrap_or_default()
));
let mut outbox = enabled_outbox_config();
outbox.transport = OutboxTransport::Liminal;
outbox.liminal_listen_address = None;
let config = ServerConfig {
store: StoreConfig {
backend: StoreBackend::LibSql,
url: Some(db_path.to_string_lossy().into_owned()),
..StoreConfig::default()
},
runtime: RuntimeSection {
scheduler_threads: 1,
query_timeout_ms: Some(10_000),
},
websocket: WebSocketConfig {
outbound_buffer_bound: 32,
event_broadcast_capacity: Some(64),
cluster_broadcast_capacity: Some(64),
},
outbox: outbox.clone(),
..ServerConfig::default()
};
let state = ServerState::build(config)
.await
.expect("build libsql state");
let (_tx, rx) = tokio::sync::watch::channel(false);
let error = maybe_spawn_outbox_dispatcher(
&state,
&outbox,
false,
test_backpressure_settings(),
&rx,
)
.expect_err("liminal transport without a listen address must be a config error");
assert!(
error.is_config(),
"missing-listen-address error must be Config"
);
assert!(
error.to_string().contains("liminal_listen_address"),
"error must name the missing knob, got: {error}"
);
}
}
#[cfg(all(test, feature = "liminal-transport", feature = "libsql-backend"))]
mod lsub_prod_xnode_e2e {
#![allow(clippy::expect_used)]
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use aion_core::Event;
use aion_package::{
ActionContract, BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest,
ManifestVersion, PackageBuilder, PackageContract, WorkerContract,
};
use aion_store::ReadableEventStore;
use aion_store_libsql::LibSqlStore;
use aion_worker::{ActivityRegistry, LiminalActivityWorker, WorkerConfig};
use axum::body;
use axum::http::{Request, StatusCode};
use serde_json::json;
use tower::ServiceExt;
use super::{BackpressureSettings, maybe_spawn_outbox_dispatcher};
use crate::ServerState;
use crate::api::http::http_router;
use crate::config::{
OutboxConfig, OutboxTransport, RuntimeSection, ServerConfig, StoreBackend, StoreConfig,
WebSocketConfig,
};
type TestError = Box<dyn std::error::Error + Send + Sync>;
type FanInput = String;
const NAMESPACE: &str = "default";
const TASK_QUEUE: &str = "default";
const OUTBOX_MODULE: &str = "aion_outbox_fixture";
const OUTBOX_BEAM: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.beam");
const OUTBOX_SOURCE: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.erl");
const FAN_OUT: usize = 4;
const FAN_ACTIVITY_TYPES: [&str; FAN_OUT] = ["fan:0", "fan:1", "fan:2", "fan:3"];
const POLL_DEADLINE: Duration = Duration::from_secs(20);
fn test_error(message: impl std::fmt::Display) -> TestError {
message.to_string().into()
}
fn reserve_loopback_port() -> Result<SocketAddr, TestError> {
let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(test_error)?;
let address = listener.local_addr().map_err(test_error)?;
drop(listener);
Ok(address)
}
fn fixture_contract(manifest: &Manifest) -> Result<PackageContract, TestError> {
let mut actions = Vec::with_capacity(FAN_ACTIVITY_TYPES.len());
for activity_type in FAN_ACTIVITY_TYPES {
let descriptor = aion_worker::activity_descriptor::<FanInput, String>(activity_type)
.map_err(test_error)?;
actions.push(ActionContract {
name: descriptor.name,
input_schema: descriptor.input_schema,
output_schema: descriptor.output_schema,
node: None,
timeout: None,
retry: None,
advisory: false,
agent: false,
body: None,
});
}
let mut contract = PackageContract::from_manifest(manifest);
contract.workers = vec![WorkerContract {
task_queue: TASK_QUEUE.to_owned(),
actions,
}];
contract.unscoped_activities.clear();
Ok(contract)
}
fn write_package_archive(dir: &std::path::Path) -> Result<PathBuf, TestError> {
let beams =
BeamSet::new(vec![BeamModule::new(OUTBOX_MODULE, OUTBOX_BEAM)]).map_err(test_error)?;
let manifest = Manifest {
entry_module: OUTBOX_MODULE.to_owned(),
entry_function: "collect_four".to_owned(),
input_schema: json!({ "type": "object" }),
output_schema: json!({}),
timeout: Some(Duration::from_secs(30)),
activities: FAN_ACTIVITY_TYPES
.iter()
.map(|activity_type| DeclaredActivity {
activity_type: (*activity_type).to_owned(),
})
.collect(),
version: ManifestVersion::new("stamped-by-builder"),
format_version: CURRENT_FORMAT_VERSION,
additional_workflows: Vec::new(),
};
let contract = fixture_contract(&manifest)?;
let archive =
PackageBuilder::with_source(manifest, beams, [(OUTBOX_MODULE, OUTBOX_SOURCE.to_vec())])
.with_contract(contract)
.write_to_bytes()
.map_err(test_error)?;
let path = dir.join("collect_four.aion");
std::fs::write(&path, archive).map_err(test_error)?;
Ok(path)
}
fn server_config(
db_path: &std::path::Path,
package_path: PathBuf,
listen_address: SocketAddr,
) -> ServerConfig {
ServerConfig {
store: StoreConfig {
backend: StoreBackend::LibSql,
url: Some(db_path.to_string_lossy().into_owned()),
..StoreConfig::default()
},
runtime: RuntimeSection {
scheduler_threads: 1,
query_timeout_ms: Some(10_000),
},
websocket: WebSocketConfig {
outbound_buffer_bound: 32,
event_broadcast_capacity: Some(64),
cluster_broadcast_capacity: Some(64),
},
workflow_packages: vec![package_path],
outbox: OutboxConfig {
enabled: true,
poll_interval_ms: Some(20),
batch_size: Some(16),
max_attempts: Some(5),
backoff_base_ms: Some(50),
backoff_multiplier: Some(2),
backoff_max_ms: Some(1_000),
reconcile_interval_ms: None,
reconcile_stale_after_ms: None,
transport: OutboxTransport::Liminal,
liminal_listen_address: Some(listen_address.to_string()),
},
..ServerConfig::default()
}
}
fn worker_config() -> Result<WorkerConfig, TestError> {
WorkerConfig::builder()
.endpoint("unused-direct-address")
.namespace(NAMESPACE)
.task_queue(TASK_QUEUE)
.identity("lsub-prod-worker")
.max_concurrency(4)
.reconnect_initial_backoff(Duration::from_millis(5))
.reconnect_max_backoff(Duration::from_millis(20))
.reconnect_max_attempts(3)
.build()
.map_err(test_error)
}
fn worker_registry(executions: &Arc<AtomicUsize>) -> Result<Arc<ActivityRegistry>, TestError> {
let mut registry = ActivityRegistry::new();
for activity_type in FAN_ACTIVITY_TYPES {
let executions = Arc::clone(executions);
registry = registry
.register_activity_with_contract(
activity_type,
move |_input: FanInput, _context| {
let executions = Arc::clone(&executions);
Box::pin(async move {
executions.fetch_add(1, Ordering::SeqCst);
Ok(activity_type.to_owned())
})
},
)
.map_err(test_error)?;
}
Ok(Arc::new(registry))
}
struct WorkerThread {
stop: Arc<std::sync::atomic::AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
}
impl WorkerThread {
fn spawn(address: String, config: WorkerConfig, registry: Arc<ActivityRegistry>) -> Self {
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
let thread_stop = Arc::clone(&stop);
let handle = std::thread::spawn(move || {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
eprintln!("worker runtime build failed: {error}");
return;
}
};
runtime.block_on(async move {
let worker = match LiminalActivityWorker::connect(&address, &config, registry) {
Ok(worker) => worker,
Err(error) => {
eprintln!("worker connect failed: {error}");
return;
}
};
if let Err(error) = worker
.serve_until(|| thread_stop.load(Ordering::SeqCst))
.await
{
eprintln!("worker serve loop ended with error: {error}");
}
});
});
Self {
stop,
handle: Some(handle),
}
}
fn stop(mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(handle) = self.handle.take() {
handle.join().ok();
}
}
}
fn count_completed(history: &[Event]) -> usize {
history
.iter()
.filter(|event| matches!(event, Event::ActivityCompleted { .. }))
.count()
}
fn count_workflow_completed(history: &[Event]) -> usize {
history
.iter()
.filter(|event| matches!(event, Event::WorkflowCompleted { .. }))
.count()
}
async fn wait_for_history<F>(
store: &LibSqlStore,
workflow_id: &aion_core::WorkflowId,
description: &str,
predicate: F,
) -> Result<Vec<Event>, TestError>
where
F: Fn(&[Event]) -> bool,
{
let deadline = Instant::now() + POLL_DEADLINE;
loop {
let history = store.read_history(workflow_id).await.map_err(test_error)?;
if predicate(&history) {
return Ok(history);
}
if Instant::now() > deadline {
return Err(test_error(format!(
"timed out waiting for {description}: {history:#?}"
)));
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
}
async fn start_over_http(router: &axum::Router) -> Result<aion_core::WorkflowId, TestError> {
let build_request = || -> Result<Request<body::Body>, TestError> {
Request::builder()
.uri("/workflows/start")
.method("POST")
.header("content-type", "application/json")
.header("x-aion-subject", "ci")
.header("x-aion-namespaces", NAMESPACE)
.body(body::Body::from(
serde_json::to_vec(&json!({
"namespace": NAMESPACE,
"workflow_type": OUTBOX_MODULE,
"input": { "fixture": "input" },
}))
.map_err(test_error)?,
))
.map_err(test_error)
};
let response = router
.clone()
.oneshot(build_request()?)
.await
.map_err(test_error)?;
let status = response.status();
let bytes = body::to_bytes(response.into_body(), usize::MAX)
.await
.map_err(test_error)?
.to_vec();
if status != StatusCode::OK {
return Err(test_error(format!(
"workflow start over HTTP must succeed, got {status}: {}",
String::from_utf8_lossy(&bytes)
)));
}
let body: serde_json::Value = serde_json::from_slice(&bytes).map_err(test_error)?;
let workflow_id = body["workflow_id"]
.as_str()
.ok_or_else(|| test_error("start response missing workflow id"))?
.parse::<uuid::Uuid>()
.map_err(test_error)?;
Ok(aion_core::WorkflowId::new(workflow_id))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn production_boot_dispatches_executes_and_records_over_liminal() -> Result<(), TestError>
{
let dir = crate::test_support::private_tempdir().map_err(test_error)?;
let db_path = dir.path().join("aion.db");
let package_path = write_package_archive(dir.path())?;
let listen_address = reserve_loopback_port()?;
let config = server_config(&db_path, package_path, listen_address);
let outbox_config = config.outbox.clone();
let state = ServerState::build(config).await.map_err(test_error)?;
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let backpressure_settings = BackpressureSettings {
platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
fraction: crate::worker::OwnedShardFraction::own_all(),
};
let listener_guard = maybe_spawn_outbox_dispatcher(
&state,
&outbox_config,
false,
backpressure_settings,
&shutdown_rx,
)
.map_err(test_error)?;
let executions = Arc::new(AtomicUsize::new(0));
let worker = WorkerThread::spawn(
listen_address.to_string(),
worker_config()?,
worker_registry(&executions)?,
);
let registry = state.worker_registry().clone();
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let ready = FAN_ACTIVITY_TYPES.iter().all(|activity_type| {
registry
.select_worker(NAMESPACE, TASK_QUEUE, activity_type, None)
.ok()
.flatten()
.is_some()
});
if ready {
break;
}
if Instant::now() > deadline {
worker.stop();
return Err(test_error("worker never registered in-band for the pool"));
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let router = http_router(state.clone()).map_err(test_error)?;
let workflow_id = start_over_http(&router).await?;
let reader = LibSqlStore::open(db_path.clone())
.await
.map_err(test_error)?;
let settled = wait_for_history(&reader, &workflow_id, "fan-out settled", |events| {
count_completed(events) == FAN_OUT && count_workflow_completed(events) == 1
})
.await?;
assert_eq!(
count_completed(&settled),
FAN_OUT,
"every fan-out member must record a terminal through the production callback"
);
assert_eq!(
count_workflow_completed(&settled),
1,
"the workflow must complete exactly once"
);
assert_eq!(
executions.load(Ordering::SeqCst),
FAN_OUT,
"the remote worker must have executed every pushed dispatch exactly once"
);
shutdown_tx.send(true).ok();
worker.stop();
drop(listener_guard);
state.shutdown().map_err(test_error)?;
Ok(())
}
}