use std::net::SocketAddr;
use std::sync::Arc;
use tracing::{error, info, warn};
use crate::{
ServerConfig, ServerError, ServerState,
config::{OutboxConfig, OutboxTransport},
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)]
pub(super) struct BackpressureSettings {
pub(super) platform_default: u32,
pub(super) fraction: crate::worker::OwnedShardFraction,
}
impl BackpressureSettings {
pub(super) 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)]
pub(super) struct OutboxWorkerListener {
#[cfg(feature = "liminal-transport")]
_inner: Option<liminal_server::server::listener::ServerListener>,
}
pub(super) 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"
);
}
}
}
pub(super) 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)
}
pub(super) 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())
.with_delivery_gate(delivery_gate);
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;
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 liminal_delivery: Arc<dyn crate::worker::task_delivery::WorkerTaskDelivery> = Arc::new(
crate::worker::liminal_task_delivery::LiminalTaskDelivery::new(Arc::new(
crate::worker::LiminalCompletionSource::new(callback)
.with_completion_fences(state.pending_activities().completion_fences()),
))
.with_attempt_owners(state.attempt_owners().clone()),
);
let push_dispatcher = ActivityDispatcher::new(registry)
.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())
.with_delivery_gate(delivery_gate)
.with_liminal_delivery(liminal_delivery);
let dispatch: Arc<dyn OutboxRowDispatch> =
Arc::new(WorkerOutboxDispatch::new(push_dispatcher).with_placement_cache(placement_cache));
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),
})
}
pub(super) 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,
}))
}