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
}
}
}
}
fn liminal_address_hint(source: &crate::config::ConfigSource) -> String {
match source {
crate::config::ConfigSource::BuiltInDefaults => {
"set AION_OUTBOX_LIMINAL_LISTEN_ADDRESS, or add `liminal_listen_address = \
\"127.0.0.1:50061\"` to `[outbox]` in a config file"
.to_owned()
}
source => format!(
"add `liminal_listen_address = \"127.0.0.1:50061\"` to `[outbox]` in the {source}"
),
}
}
async fn run_server(cli: CliOverrides) -> Result<ExitCode, ServerError> {
observability::tracing::init()?;
let loaded = crate::config::load_or_scaffold(&cli)?;
loaded.resolution.ensure_private_home()?;
let death_note = crate::death_note::DeathNote::arm(&loaded.resolution.home)?;
loaded.resolution.log_startup();
let liminal_address_hint = liminal_address_hint(&loaded.resolution.source);
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);
let cluster_config = config.store.cluster.clone();
let supervision_policy = config.worker_supervision.resolve()?;
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,
death_note = %death_note.path().display(),
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"
);
crate::assistant::install_embedded_assistant_for_server(&state, &liminal_address_hint).await;
crate::update_check::install_embedded_update_check_for_server(&state).await;
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let outbox_clustered = cluster_config.is_some();
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,
&liminal_address_hint,
)?;
maybe_spawn_cluster_supervisor(&state, cluster_config.as_ref(), &shutdown_rx)?;
drop(state.spawn_heartbeat_sweeper(shutdown_rx.clone()));
commission_worker_supervision(&state, supervision_policy).await;
drop(state.spawn_startup_catchup(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
},
};
let exit_code = outcome.exit_code();
death_note.disarm(&format!(
"clean run-loop exit: shutdown outcome {outcome:?}"
));
Ok(exit_code)
}
async fn commission_worker_supervision(
state: &ServerState,
policy: Option<crate::worker::SupervisionPolicy>,
) {
let supervisor = state.worker_supervisor();
let Some(policy) = policy else {
match supervisor.report().await {
Ok(report) => {
let wanted: Vec<&str> = report
.workers
.iter()
.filter(|worker| worker.desired == aion_store::DesiredState::Running)
.map(|worker| worker.name.as_str())
.collect();
if wanted.is_empty() {
info!("managed-worker supervision is not configured; no deployment wants it");
} else {
warn!(
deployments = wanted.join(", "),
remedy = crate::worker::supervisor::UNCOMMISSIONED_REMEDY,
"worker deployments want to be running but supervision is not configured"
);
}
}
Err(error) => error!(
%error,
"managed-worker supervision is not configured and the deployment records \
could not be read to say what that costs"
),
}
return;
};
if !supervisor.commission(policy, crate::worker::ManagedExecutable::CurrentServer) {
error!("managed-worker supervision was already commissioned before boot completed");
return;
}
match supervisor.reconcile().await {
Ok(0) => info!("managed-worker supervision commissioned; no deployment wants to run"),
Ok(supervised) => info!(supervised, "managed-worker supervision commissioned"),
Err(error) => error!(%error, "managed-worker fleet could not be converged at boot"),
}
}
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>,
liminal_address_hint: &str,
) -> 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=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),
liminal_address_hint,
)?;
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)
}
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>,
liminal_address_hint: &str,
) -> 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(),
)
.with_cluster_publisher(state.cluster_publisher().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,
liminal_address_hint,
),
}
}
#[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>,
liminal_address_hint: &str,
) -> 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: format!(
"outbox.transport=liminal requires outbox.liminal_listen_address (host:port \
the aion-server listens on for inbound liminal worker connections); \
{liminal_address_hint}"
),
})?;
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>,
_liminal_address_hint: &str,
) -> 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::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::with_flush_policy(64, 0),
mcp: crate::config::ResolvedMcpConfig::default(),
scheduler_threads: 1,
jit_threshold: None,
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,
"set outbox.liminal_listen_address in the test config",
)
.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("store.backend=haematite"),
"message must name the durable backend, 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,
"set outbox.liminal_listen_address in the test config",
)
.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(feature = "liminal-transport")]
#[tokio::test]
async fn liminal_transport_requires_listen_address() {
use crate::config::{
RuntimeSection, ServerConfig, StoreBackend, StoreConfig, WebSocketConfig,
};
let data_dir = std::env::temp_dir().join(format!(
"aion-lsub-prod-listen-guard-{}-{}",
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::Haematite,
data_dir: Some(data_dir.to_string_lossy().into_owned()),
node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
..StoreConfig::default()
},
runtime: RuntimeSection {
scheduler_threads: 1,
jit_threshold: None,
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(),
observability: crate::config::ObservabilityConfig::with_flush_policy(64, 0),
..ServerConfig::default()
};
let state = ServerState::build(config)
.await
.expect("build haematite state");
let (_tx, rx) = tokio::sync::watch::channel(false);
let error = maybe_spawn_outbox_dispatcher(
&state,
&outbox,
false,
test_backpressure_settings(),
&rx,
"add `liminal_listen_address = \"127.0.0.1:50061\"` to `[outbox]` in the test config",
)
.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}"
);
assert!(
error.to_string().contains("in the test config"),
"error must carry the threaded config-location hint, got: {error}"
);
}
}
#[cfg(all(test, feature = "liminal-transport"))]
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_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);
const HELD_ACTIVITY_TYPE: &str = FAN_ACTIVITY_TYPES[0];
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(
data_dir: &std::path::Path,
package_path: PathBuf,
listen_address: SocketAddr,
) -> ServerConfig {
ServerConfig {
store: StoreConfig {
backend: StoreBackend::Haematite,
data_dir: Some(data_dir.to_string_lossy().into_owned()),
node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
..StoreConfig::default()
},
runtime: RuntimeSection {
scheduler_threads: 1,
jit_threshold: None,
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()),
},
observability: crate::config::ObservabilityConfig::with_flush_policy(64, 0),
..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 spawn_redialing(
address: String,
config: WorkerConfig,
registry: Arc<ActivityRegistry>,
timing: aion_worker::RedialTiming,
) -> Self {
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
let thread_stop = Arc::clone(&stop);
let handle = std::thread::spawn(move || {
if let Err(error) = aion_worker::serve_with_redial(
vec![address],
&config,
®istry,
timing,
&thread_stop,
None,
|| {},
) {
eprintln!("redialing worker 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: &dyn aion_store::ReadableEventStore,
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))
}
fn eligibility_patience(config: &ServerConfig) -> Duration {
let cadence = crate::worker::sweep_interval(config.worker.heartbeat_window);
cadence * (crate::worker::heartbeat::DISPATCH_PROBATION_PINGS + 1)
}
async fn wait_for_registration(
registry: &crate::worker::ConnectedWorkerRegistry,
heartbeat: &crate::worker::HeartbeatTracker,
listen_address: SocketAddr,
patience: Duration,
) -> Result<(), TestError> {
let deadline = Instant::now() + patience;
loop {
let now = Instant::now();
let mut ready = true;
for activity_type in FAN_ACTIVITY_TYPES {
let Some(worker) = registry
.select_worker(NAMESPACE, TASK_QUEUE, activity_type, None)
.map_err(test_error)?
else {
ready = false;
break;
};
if !heartbeat
.is_dispatch_reachable(worker.id(), now)
.map_err(test_error)?
{
ready = false;
break;
}
}
if ready {
return Ok(());
}
if Instant::now() > deadline {
return Err(test_error(format!(
"worker never registered in-band for the pool within {patience:?}{}",
registration_diagnosis(registry, listen_address)
)));
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
fn registration_diagnosis(
registry: &crate::worker::ConnectedWorkerRegistry,
listen_address: SocketAddr,
) -> String {
let mut lines = vec![String::from("--- registration diagnosis ---")];
lines.push(
match std::net::TcpStream::connect_timeout(&listen_address, Duration::from_millis(500))
{
Ok(stream) => {
drop(stream);
format!("listener {listen_address}: ACCEPTS — the port is bound and dialable")
}
Err(error) => format!(
"listener {listen_address}: NOT connectable ({error}) — nothing could have \
registered, so this is not a timing problem"
),
},
);
match registry.all_workers() {
Err(error) => lines.push(format!("registry: UNREADABLE ({error})")),
Ok(workers) if workers.is_empty() => lines.push(String::from(
"registry: EMPTY — no worker of any pool registered, so no connection ever \
completed an in-band registration",
)),
Ok(workers) => {
lines.push(format!("registry: {} worker(s) registered", workers.len()));
for worker in &workers {
lines.push(format!(
" id={:?} namespaces={:?} task_queue={:?} node={:?} types={:?}",
worker.id(),
worker.namespaces(),
worker.task_queue(),
worker.node(),
worker.activity_types()
));
}
}
}
lines.push(format!(
"asked of it: namespace={NAMESPACE:?} task_queue={TASK_QUEUE:?}"
));
lines.push(match registry.dispatch_ineligible() {
Ok(ineligible) if ineligible.is_empty() => {
String::from("dispatch-ineligible: none — reachability is not refusing anyone")
}
Ok(ineligible) => format!(
"dispatch-ineligible: {ineligible:?} — the liveness probe has published these \
as unreachable and select_worker skips them"
),
Err(error) => format!("dispatch-ineligible: UNREADABLE ({error})"),
});
for activity_type in FAN_ACTIVITY_TYPES {
let outcome = match registry.select_worker(NAMESPACE, TASK_QUEUE, activity_type, None) {
Ok(Some(handle)) => format!("worker {:?}", handle.id()),
Ok(None) => String::from("NO worker"),
Err(error) => format!("error: {error}"),
};
let census = match registry.pool_census(NAMESPACE, TASK_QUEUE, activity_type, None) {
Ok(census) => format!(
"in_pool={} serving_activity={} compatible={} last_compatible_age={:?}",
census.workers_in_pool,
census.workers_serving_activity,
census.compatible_workers,
census.last_compatible_poller_age
),
Err(error) => format!("census UNREADABLE ({error})"),
};
lines.push(format!(
"select_worker({activity_type}) -> {outcome} [census: {census}]"
));
}
format!("\n {}", lines.join("\n "))
}
#[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 patience = eligibility_patience(&config);
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,
"set outbox.liminal_listen_address in the test config",
)
.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();
if let Err(error) = wait_for_registration(
®istry,
state.heartbeat_tracker(),
listen_address,
patience,
)
.await
{
worker.stop();
return Err(error);
}
let router = http_router(state.clone()).map_err(test_error)?;
let workflow_id = start_over_http(&router).await?;
let reader = state.engine().map_err(test_error)?.store();
let settled =
wait_for_history(reader.as_ref(), &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(())
}
#[derive(Clone, Debug)]
struct SeenDispatch {
activity_type: String,
activity_id: String,
attempt: u32,
at: Instant,
}
struct SeverableRelay {
address: SocketAddr,
sockets: Arc<std::sync::Mutex<Vec<std::net::TcpStream>>>,
stop: Arc<std::sync::atomic::AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
}
impl SeverableRelay {
fn spawn(upstream: SocketAddr) -> Result<Self, 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)?;
listener.set_nonblocking(true).map_err(test_error)?;
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
let sockets: Arc<std::sync::Mutex<Vec<std::net::TcpStream>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let accept_stop = Arc::clone(&stop);
let accept_sockets = Arc::clone(&sockets);
let handle = std::thread::spawn(move || {
while !accept_stop.load(Ordering::SeqCst) {
match listener.accept() {
Ok((downstream, _)) => {
if let Err(error) =
Self::relay_one(&downstream, upstream, &accept_sockets)
{
eprintln!("relay could not carry a connection: {error}");
}
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(2));
}
Err(error) => {
eprintln!("relay accept failed: {error}");
return;
}
}
}
});
Ok(Self {
address,
sockets,
stop,
handle: Some(handle),
})
}
fn relay_one(
downstream: &std::net::TcpStream,
upstream: SocketAddr,
sockets: &Arc<std::sync::Mutex<Vec<std::net::TcpStream>>>,
) -> Result<(), TestError> {
downstream.set_nonblocking(false).map_err(test_error)?;
let up = std::net::TcpStream::connect(upstream).map_err(test_error)?;
let down_read = downstream.try_clone().map_err(test_error)?;
let down_write = downstream.try_clone().map_err(test_error)?;
let up_read = up.try_clone().map_err(test_error)?;
let up_write = up.try_clone().map_err(test_error)?;
let held = downstream.try_clone().map_err(test_error)?;
let mut parked = sockets
.lock()
.map_err(|_| test_error("relay socket register poisoned"))?;
parked.push(held);
parked.push(up);
drop(parked);
for (from, to) in [(down_read, up_write), (up_read, down_write)] {
std::thread::spawn(move || Self::pump(from, to));
}
Ok(())
}
fn pump(mut from: std::net::TcpStream, mut to: std::net::TcpStream) {
use std::io::{Read, Write};
let mut buffer = [0_u8; 8192];
loop {
match from.read(&mut buffer) {
Ok(0) | Err(_) => return,
Ok(read) => {
if to.write_all(&buffer[..read]).is_err() {
return;
}
}
}
}
}
const fn address(&self) -> SocketAddr {
self.address
}
fn sever(&self) -> Result<usize, TestError> {
let mut parked = self
.sockets
.lock()
.map_err(|_| test_error("relay socket register poisoned"))?;
let mut severed = 0;
for socket in parked.iter() {
if socket.shutdown(std::net::Shutdown::Both).is_ok() {
severed += 1;
}
}
parked.clear();
Ok(severed)
}
fn shutdown(mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(handle) = self.handle.take() {
handle.join().ok();
}
}
}
fn recording_registry(
seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>,
release: &Arc<std::sync::atomic::AtomicBool>,
) -> Result<Arc<ActivityRegistry>, TestError> {
let mut registry = ActivityRegistry::new();
for activity_type in FAN_ACTIVITY_TYPES {
let seen = Arc::clone(seen);
let release = Arc::clone(release);
let arrivals = Arc::new(AtomicUsize::new(0));
registry = registry
.register_activity_with_contract(
activity_type,
move |_input: FanInput, context: &aion_worker::ActivityContext| {
let seen = Arc::clone(&seen);
let release = Arc::clone(&release);
let arrivals = Arc::clone(&arrivals);
let record = SeenDispatch {
activity_type: activity_type.to_owned(),
activity_id: context.activity_id().to_string(),
attempt: context.attempt(),
at: Instant::now(),
};
Box::pin(async move {
match seen.lock() {
Ok(mut log) => log.push(record),
Err(_) => {
return Err(aion_worker::ActivityFailure::terminal(
"the pin's dispatch log is poisoned, so this run can \
observe nothing — failing loudly rather than \
returning a result no assertion could trust",
));
}
}
let first = arrivals.fetch_add(1, Ordering::SeqCst) == 0;
if activity_type == HELD_ACTIVITY_TYPE && first {
while !release.load(Ordering::SeqCst) {
tokio::time::sleep(Duration::from_millis(5)).await;
}
}
Ok(activity_type.to_owned())
})
},
)
.map_err(test_error)?;
}
Ok(Arc::new(registry))
}
fn dispatches_of(
seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>,
activity_type: &str,
) -> Result<Vec<SeenDispatch>, TestError> {
let log = seen
.lock()
.map_err(|_| test_error("the pin's dispatch log is poisoned"))?;
Ok(log
.iter()
.filter(|record| record.activity_type == activity_type)
.cloned()
.collect())
}
fn dispatch_log(seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>) -> String {
match seen.lock() {
Ok(log) => format!("{:#?}", *log),
Err(_) => String::from("<poisoned>"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_completion_lost_to_a_severed_link_is_re_dispatched_and_the_workflow_settles()
-> 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 patience = eligibility_patience(&config);
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,
"set outbox.liminal_listen_address in the test config",
)
.map_err(test_error)?;
let relay = SeverableRelay::spawn(listen_address)?;
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
let release = Arc::new(std::sync::atomic::AtomicBool::new(false));
let config = worker_config()?;
let timing = aion_worker::RedialTiming::new(
config.reconnect.initial_backoff,
config.reconnect.max_backoff,
);
let worker = WorkerThread::spawn_redialing(
relay.address().to_string(),
config,
recording_registry(&seen, &release)?,
timing,
);
let outcome =
observe_reconnect(&state, &relay, &seen, &release, listen_address, patience).await;
shutdown_tx.send(true).ok();
release.store(true, Ordering::SeqCst);
worker.stop();
relay.shutdown();
drop(listener_guard);
state.shutdown().map_err(test_error)?;
outcome
}
async fn await_held_dispatch(
reader: &dyn aion_store::ReadableEventStore,
workflow_id: &aion_core::WorkflowId,
seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>,
) -> Result<(SeenDispatch, usize), TestError> {
let deadline = Instant::now() + POLL_DEADLINE;
loop {
if let Some(first) = dispatches_of(seen, HELD_ACTIVITY_TYPE)?.first() {
let at_the_break = reader.read_history(workflow_id).await.map_err(test_error)?;
return Ok((first.clone(), count_completed(&at_the_break)));
}
if Instant::now() > deadline {
let history = reader.read_history(workflow_id).await.map_err(test_error)?;
return Err(test_error(format!(
"{HELD_ACTIVITY_TYPE} was never dispatched at all within {POLL_DEADLINE:?}, \
so there was no held completion to lose and this run measured nothing.\n\
dispatch log: {}\nhistory: {history:#?}",
dispatch_log(seen),
)));
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
}
async fn observe_reconnect(
state: &ServerState,
relay: &SeverableRelay,
seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>,
release: &Arc<std::sync::atomic::AtomicBool>,
listen_address: SocketAddr,
patience: Duration,
) -> Result<(), TestError> {
wait_for_registration(
state.worker_registry(),
state.heartbeat_tracker(),
listen_address,
patience,
)
.await?;
let router = http_router(state.clone()).map_err(test_error)?;
let workflow_id = start_over_http(&router).await?;
let reader = state.engine().map_err(test_error)?.store();
let (first, settled_before) =
await_held_dispatch(reader.as_ref(), &workflow_id, seen).await?;
let severed = relay.sever()?;
let severed_at = Instant::now();
if severed == 0 {
return Err(test_error(
"the relay severed NOTHING, so no link was ever broken and this run measured \
nothing — a pass here would have been an artefact of the instrument",
));
}
release.store(true, Ordering::SeqCst);
let settled = wait_for_history(
reader.as_ref(),
&workflow_id,
"the workflow to settle after the severed link",
|events| count_completed(events) == FAN_OUT && count_workflow_completed(events) == 1,
)
.await
.map_err(|error| {
test_error(format!(
"O4 FAILED — the workflow did not settle after the link broke ({severed} \
socket(s) severed), so the lost completion cost the workflow rather than \
costing a repeat of the work.\n{error}\ndispatch log: {}",
dispatch_log(seen),
))
})?;
assert_eq!(
count_completed(&settled),
FAN_OUT,
"every fan-out member must still record a terminal after the link broke"
);
assert_eq!(
count_workflow_completed(&settled),
1,
"the workflow must complete exactly once even though a completion was lost"
);
let held = dispatches_of(seen, HELD_ACTIVITY_TYPE)?;
match held.get(1) {
None => println!(
"aion#69 — {HELD_ACTIVITY_TYPE} ({}) was NOT re-dispatched and the workflow \
still settled, so the held completion survived the break; {settled_before} of \
{FAN_OUT} members had settled when it broke, {severed} socket(s) severed",
first.activity_id,
),
Some(second) => {
if second.activity_id != first.activity_id {
return Err(test_error(format!(
"O2 FAILED — the re-delivery carried a DIFFERENT activity identity. The \
first dispatch was {} (attempt {}) and the second was {} (attempt {}), \
so the work was not re-run under its own identity and #69's framing \
does not describe what happened here.",
first.activity_id, first.attempt, second.activity_id, second.attempt,
)));
}
let recovery = second.at.saturating_duration_since(severed_at);
println!(
"aion#69 O3 — re-delivery of {} ({}) took {}ms from the link breaking; \
first attempt {}, second attempt {}; {settled_before} of {FAN_OUT} members \
had already recorded a terminal when the link broke; {severed} socket(s) \
severed",
HELD_ACTIVITY_TYPE,
first.activity_id,
recovery.as_millis(),
first.attempt,
second.attempt,
);
}
}
let all = seen
.lock()
.map_err(|_| test_error("the pin's dispatch log is poisoned"))?
.len();
println!(
"aion#69 — {all} dispatch(es) served for {FAN_OUT} activities; the transport is \
at-least-once, so the excess is the repeated work a broken link costs"
);
Ok(())
}
}