aion_server/run.rs
1//! Run loop for the Aion workflow server: tracing initialization,
2//! configuration load, transport startup, and signal-driven graceful
3//! shutdown.
4//!
5//! This is the library entry point behind the `aion server` command. It
6//! preserves the operational contract of the former standalone
7//! `aion-server` binary: exit code 2 for configuration errors, the drain
8//! outcome's exit code on shutdown, and 130 when a second termination
9//! signal forces immediate exit.
10
11use std::{net::SocketAddr, process::ExitCode};
12
13use tokio::net::TcpListener;
14use tonic::transport::Server as TonicServer;
15use tracing::{error, info, warn};
16
17use std::sync::Arc;
18
19use crate::{
20 ServerConfig, ServerError, ServerState, api,
21 config::{CliOverrides, NamespaceMode, OutboxConfig, OutboxTransport, StoreBackend},
22 observability,
23 shutdown::{self, ShutdownOutcome},
24 worker::{
25 ActivityDispatcher, OutboxDispatcher, OutboxDispatcherConfig, OutboxReconciler,
26 OutboxReconcilerConfig, OutboxRowDispatch, WorkerOutboxDispatch,
27 },
28};
29
30/// Short TTL for the dispatcher's per-namespace placement cache (Control-Plane
31/// Phase 2, P2-P3). Kept small so an operator's `PUT /namespaces/{name}/placement`
32/// takes effect on the hot claim loop within a couple of seconds, while still
33/// collapsing a per-sweep quorum `get_namespace` into a cheap in-process lookup.
34/// A stale entry under `Prefer` only mis-prefers a worker for at most one window
35/// and self-corrects — it never affects correctness or replay.
36const PLACEMENT_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
37
38/// Short TTL for the dispatcher's per-namespace quota cache (Control-Plane Phase 2,
39/// P2-Q2). Kept small so an operator raising/lowering a tenant's
40/// `max_in_flight_activities` takes effect on the hot claim loop within a couple of
41/// seconds, while still collapsing a per-sweep quorum `get_namespace` into a cheap
42/// in-process lookup. A stale entry only over- or under-admits slightly for one
43/// window and self-corrects — backpressure never drops a row, so it cannot affect
44/// correctness or replay.
45const QUOTA_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
46
47/// Cadence of the ops-console quota-state broadcaster (Control-Plane Phase 2,
48/// P2-Q3). Each tick samples every registry namespace's durable Claimed-row count
49/// and cluster-wide ceiling, then pushes one `NamespaceQuotaState` per namespace
50/// onto the cluster channel, so the console badge tracks live load. Kept at 1s:
51/// brisk enough that the badge visibly ticks as work flows, throttled enough that
52/// it is never a per-row firehose (in-flight changes on every claim/settle). It is
53/// a server-side push on a timer, NOT a client poll — the dashboard rule bans the
54/// latter, not a throttled server snapshot of REAL durable state.
55const QUOTA_BROADCAST_CADENCE: std::time::Duration = std::time::Duration::from_secs(1);
56
57/// Resolved keyed-backpressure inputs for the outbox dispatcher (Control-Plane
58/// Phase 2, P2-Q2): the generous platform-default ceiling and this node's
59/// owned-shard fraction of the cluster shard space.
60#[derive(Clone, Copy, Debug)]
61struct BackpressureSettings {
62 /// The `[namespaces] max_in_flight_activities` platform default, applied to any
63 /// namespace carrying no explicit per-tenant override.
64 platform_default: u32,
65 /// This node's owned-shard fraction of the cluster's virtual shard space,
66 /// derived from `[store] owned_shards` and `[store] shard_count`.
67 fraction: crate::worker::OwnedShardFraction,
68}
69
70impl BackpressureSettings {
71 /// Derive the backpressure inputs from the merged server config.
72 ///
73 /// An empty `[store] owned_shards` means own-all (the single-node default), so
74 /// the fraction is 1 and per-node ceilings equal the cluster-wide quota. A
75 /// declared owned set enforces the proportional per-node slice
76 /// `|owned| / shard_count` (CP-Phase-2 §3.6).
77 fn from_config(config: &ServerConfig) -> Self {
78 let total = u32::try_from(config.store.shard_count).unwrap_or(u32::MAX);
79 let fraction = if config.store.owned_shards.is_empty() {
80 crate::worker::OwnedShardFraction::own_all()
81 } else {
82 let owned = u32::try_from(config.store.owned_shards.len()).unwrap_or(u32::MAX);
83 crate::worker::OwnedShardFraction::new(owned, total)
84 };
85 Self {
86 platform_default: config.namespaces.max_in_flight_activities,
87 fraction,
88 }
89 }
90}
91
92/// Owns the liminal worker listener for the server's lifetime when the outbox is
93/// commissioned over the liminal transport.
94///
95/// The aion-server HOSTS the liminal listener that remote workers connect IN to;
96/// its inner [`ServerListener`](liminal_server::server::listener::ServerListener)
97/// owns the accept worker. Held as a local in [`run_server`] across the whole
98/// serve `select!`, so it is dropped exactly at server shutdown — and the
99/// listener's own `Drop` stops the accept worker cleanly (no leaked thread, no
100/// orphaned listener). Every non-liminal boot (the default) carries the `None`
101/// guard, which holds nothing and drops to a no-op, so behaviour is unchanged.
102#[derive(Debug, Default)]
103struct OutboxWorkerListener {
104 /// Held purely for its `Drop` side-effect (stopping the accept worker on
105 /// server shutdown); never read after construction, hence the leading
106 /// underscore.
107 #[cfg(feature = "liminal-transport")]
108 _inner: Option<liminal_server::server::listener::ServerListener>,
109}
110
111/// Run the Aion workflow server until it shuts down, returning the process
112/// exit code.
113///
114/// Initializes the JSON tracing subscriber, loads and validates the merged
115/// configuration (file, environment, then `overrides`), serves the gRPC and
116/// HTTP transports, and drains gracefully after the first termination
117/// signal. Every failure is logged through tracing and mapped to the exit
118/// code contract above; the caller only has to exit with the returned code.
119pub async fn run(overrides: CliOverrides) -> ExitCode {
120 match run_server(overrides).await {
121 Ok(code) => code,
122 Err(error) => {
123 error!(%error, "aion-server failed");
124 if error.is_config() {
125 ExitCode::from(2)
126 } else {
127 ExitCode::FAILURE
128 }
129 }
130 }
131}
132
133async fn run_server(cli: CliOverrides) -> Result<ExitCode, ServerError> {
134 observability::tracing::init()?;
135
136 let config = ServerConfig::load(&cli)?;
137 reject_auth_without_feature(&config)?;
138 let store_backend = config.store.backend;
139 // Static shard assignment (SS-1): read the operator's pinned shard set from
140 // `[store] owned_shards`. Empty means own ALL shards (single-node default).
141 // The set is carried into `RuntimeConfig` by `into_parts` and applied to the
142 // `EngineBuilder` during state construction; surface it here so the boot
143 // banner records which shards this node serves. No election is performed.
144 let owned_shards = config.store.owned_shards.clone();
145 // Capture the outbox settings before `build` consumes `config`, so the
146 // (default-off) outbox dispatcher can be wired after state is up. The
147 // dispatcher shares the engine's already-opened libSQL store (one
148 // connection) via `state.outbox_store()`, so no store settings are needed.
149 let outbox_config = config.outbox.clone();
150 // Control-Plane Phase 2 (P2-Q2): capture the keyed-backpressure inputs — the
151 // generous platform-default ceiling and this node's owned-shard fraction —
152 // before `build` consumes `config`. On a single-node / own-all boot the fraction
153 // is 1, so per-node ceilings equal the cluster-wide quota and, with the generous
154 // default and no tenant override, the ceiling never engages (byte-identical claim).
155 let backpressure_settings = BackpressureSettings::from_config(&config);
156 // Capture the SS-5b failover supervisor knobs before `build` consumes config.
157 // Only a distributed haematite boot carries a `[store.cluster]` section; this
158 // is `None` for every single-node boot, so no supervisor is ever spawned.
159 #[cfg(feature = "haematite-backend")]
160 let cluster_config = config.store.cluster.clone();
161 let state = ServerState::build(config).await?;
162 reject_tls_until_supported(&state)?;
163
164 let runtime = state.runtime_config();
165 let grpc_address = runtime.listen.grpc;
166 let http_address = runtime.listen.http;
167 let workflow_packages: Vec<String> = runtime
168 .workflow_packages
169 .iter()
170 .map(|path| path.display().to_string())
171 .collect();
172 info!(
173 version = env!("CARGO_PKG_VERSION"),
174 grpc_address = %grpc_address,
175 http_address = %http_address,
176 default_namespace = %runtime.default_namespace,
177 namespace_mode = namespace_mode_label(&runtime.namespace.mode),
178 store_backend = store_backend_label(store_backend),
179 auth_enabled = runtime.auth.enabled,
180 deploy_enabled = runtime.deploy.enabled,
181 metrics_enabled = runtime.metrics.enabled,
182 workflow_package_count = workflow_packages.len(),
183 workflow_packages = ?workflow_packages,
184 owned_shards = ?owned_shards,
185 owns_all_shards = owned_shards.is_empty(),
186 "aion-server startup banner"
187 );
188 let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
189 // LSUB-4-1: a distributed haematite boot carries a `[store.cluster]` section.
190 // The single outbox dispatcher task is spawned in BOTH modes; the difference
191 // is only how ownership is enforced. Single-node (`None`) owns all shards by
192 // construction (`owned_shard_scope() == None`), so its claim sweeps see every
193 // row. Clustered (`Some`) relies on `claim_outbox_rows`' `owned_shard_scope()`
194 // filter — already seeded by `set_owned_shards` during `ServerState::build`,
195 // which runs before this point — so each node only ever claims rows on the
196 // shards it owns. Compute the flag here where the (feature-gated) cluster
197 // section is in scope; pass it to the gate so the boot banner records the mode.
198 #[cfg(feature = "haematite-backend")]
199 let outbox_clustered = cluster_config.is_some();
200 #[cfg(not(feature = "haematite-backend"))]
201 let outbox_clustered = false;
202 // Dormant by default: only when `outbox.enabled` is set does the
203 // non-replayed outbox dispatcher task start. With the flag off (the
204 // default) nothing here runs and server behaviour is unchanged.
205 // Hold the liminal worker listener (if any) for the server's lifetime: it is
206 // dropped at the end of `run_server`, after the serve `select!` completes, so
207 // its accept worker stops cleanly on shutdown via the listener's own `Drop`.
208 // #204/#253: rebuild the pause dispatch-hold and settle terminal
209 // workflows' stranded outbox rows BEFORE the dispatcher's first claim.
210 rebuild_outbox_boot_state(&state, &outbox_config).await;
211 let _outbox_worker_listener = maybe_spawn_outbox_dispatcher(
212 &state,
213 &outbox_config,
214 outbox_clustered,
215 backpressure_settings,
216 &shutdown_rx,
217 )?;
218 // SS-5b: a distributed boot whose peers declare owned shards runs the cluster
219 // supervisor — automatic failover detection. A single-node boot spawns
220 // nothing here (the method returns `false`), so default behaviour is
221 // unchanged.
222 #[cfg(feature = "haematite-backend")]
223 maybe_spawn_cluster_supervisor(&state, cluster_config.as_ref(), &shutdown_rx)?;
224 // #176: the worker heartbeat expiry sweeper is ALWAYS commissioned —
225 // dead-worker detection is a liveness correctness property, not an opt-in
226 // feature. It is the production caller of `fail_expired_workers`: a worker
227 // whose stream stays open while its process wedges (stops heartbeating
228 // without disconnecting) is expired, deregistered with the provable Timeout
229 // reason, and its in-flight tasks surface as retryable lost-worker failures.
230 // Cadence derives from `worker.heartbeat_window` (quarter-window, clamped to
231 // [1s, window]; the default 30s window sweeps every 7.5s) — deliberately no
232 // separate config knob. It drains on the same shutdown watch as the
233 // transports; dropping the JoinHandle only detaches the task.
234 drop(state.spawn_heartbeat_sweeper(shutdown_rx.clone()));
235 let mut grpc = tokio::spawn(serve_grpc(state.clone(), grpc_address, shutdown_rx.clone()));
236 let mut http = tokio::spawn(serve_http(state.clone(), http_address, shutdown_rx));
237
238 let outcome = tokio::select! {
239 result = &mut grpc => {
240 transport_result("gRPC", result)?;
241 state.shutdown()?;
242 ShutdownOutcome::Clean
243 },
244 result = &mut http => {
245 transport_result("HTTP", result)?;
246 state.shutdown()?;
247 ShutdownOutcome::Clean
248 },
249 result = shutdown_signal() => {
250 result?;
251 let _receiver_count = shutdown_tx.send(true);
252 let outcome = shutdown::drain_after_first_signal(state.clone(), async {
253 let _ = shutdown_signal().await;
254 }).await?;
255 if !matches!(outcome, ShutdownOutcome::Forced) {
256 transport_result("gRPC", grpc.await)?;
257 transport_result("HTTP", http.await)?;
258 }
259 outcome
260 },
261 };
262
263 Ok(outcome.exit_code())
264}
265
266fn transport_result(
267 transport: &'static str,
268 result: Result<Result<(), ServerError>, tokio::task::JoinError>,
269) -> Result<(), ServerError> {
270 match result {
271 Ok(transport_outcome) => transport_outcome,
272 Err(join_error) => Err(ServerError::Transport {
273 transport,
274 message: join_error.to_string(),
275 }),
276 }
277}
278
279async fn serve_grpc(
280 state: ServerState,
281 address: SocketAddr,
282 shutdown: tokio::sync::watch::Receiver<bool>,
283) -> Result<(), ServerError> {
284 let workflow = api::grpc::workflow_service(state.clone());
285 let worker = api::worker_grpc::worker_service(state.clone());
286 let mut router = TonicServer::builder()
287 .add_service(workflow)
288 .add_service(worker);
289 // Dark by default: the deploy service joins the listener only when the
290 // operator commissioned it; otherwise the surface answers Unimplemented.
291 if state.runtime_config().deploy.enabled {
292 router = router.add_service(api::deploy_grpc::deploy_service(state)?);
293 }
294 router
295 .serve_with_shutdown(address, shutdown_requested(shutdown))
296 .await
297 .map_err(|source| transport_bind("grpc", address, source))?;
298 Ok(())
299}
300
301async fn serve_http(
302 state: ServerState,
303 address: SocketAddr,
304 shutdown: tokio::sync::watch::Receiver<bool>,
305) -> Result<(), ServerError> {
306 let listener = TcpListener::bind(address)
307 .await
308 .map_err(|source| transport_bind("http", address, source))?;
309 axum::serve(listener, api::http::http_router(state)?)
310 .with_graceful_shutdown(shutdown_requested(shutdown))
311 .await
312 .map_err(|source| transport_bind("http", address, source))?;
313 Ok(())
314}
315
316async fn shutdown_requested(mut shutdown: tokio::sync::watch::Receiver<bool>) {
317 while !*shutdown.borrow_and_update() {
318 if shutdown.changed().await.is_err() {
319 break;
320 }
321 }
322}
323
324async fn shutdown_signal() -> Result<(), ServerError> {
325 #[cfg(unix)]
326 {
327 use tokio::signal::unix::{SignalKind, signal};
328
329 let mut terminate = signal(SignalKind::terminate())
330 .map_err(|source| signal_listener("SIGTERM", &source))?;
331 let mut interrupt =
332 signal(SignalKind::interrupt()).map_err(|source| signal_listener("SIGINT", &source))?;
333 tokio::select! {
334 _ = terminate.recv() => Ok(()),
335 _ = interrupt.recv() => Ok(()),
336 }
337 }
338
339 #[cfg(not(unix))]
340 {
341 tokio::signal::ctrl_c()
342 .await
343 .map_err(|source| signal_listener("shutdown signal", &source))
344 }
345}
346
347fn signal_listener(listener: &'static str, source: &std::io::Error) -> ServerError {
348 ServerError::SignalListener {
349 listener,
350 message: source.to_string(),
351 }
352}
353
354fn reject_auth_without_feature(config: &ServerConfig) -> Result<(), ServerError> {
355 if cfg!(not(feature = "auth")) && config.auth.enabled {
356 return Err(ServerError::Config {
357 message: "auth.enabled=true but binary compiled without auth feature".to_owned(),
358 });
359 }
360 Ok(())
361}
362
363/// Rebuild the outbox-related boot state BEFORE the dispatcher's first claim,
364/// when (and only when) the outbox is commissioned:
365///
366/// - #204: repopulate the durable pause dispatch-hold from `list_paused`, so a
367/// run paused before a restart keeps its outbox rows held (never claimed)
368/// after recovery. A run projecting `Paused` is excluded from `list_active`
369/// respawn for free; this repopulates the hold that would otherwise be empty
370/// in memory after a crash.
371/// - #253: settle terminal workflows' stranded outbox rows. A workflow that
372/// reached a durable terminal without its rows being settled (a settle-hook
373/// failure, or a crash between the terminal append and the settle) must not
374/// have those rows re-armed and redelivered after restart — that is the
375/// zombie-round incident. A sweep error is loud but non-fatal: the
376/// settle-at-terminal hook and the reconciler's liveness gate remain as
377/// repair paths, and the residual window is one bounded dispatch whose
378/// completion drops unmatched, never a re-arm loop.
379async fn rebuild_outbox_boot_state(state: &ServerState, outbox_config: &OutboxConfig) {
380 if !outbox_config.enabled {
381 return;
382 }
383 let Ok(engine) = state.engine() else {
384 return;
385 };
386 if let Err(error) = engine.rebuild_paused_runs().await {
387 warn!(%error, "failed to rebuild paused-runs dispatch hold at startup");
388 }
389 let Some(outbox_store) = state.outbox_store() else {
390 return;
391 };
392 match crate::worker::settle_terminal_outbox_rows(engine.store().as_ref(), outbox_store.as_ref())
393 .await
394 {
395 Ok(settled) if settled.is_empty() => {}
396 Ok(settled) => {
397 info!(
398 settled = settled.len(),
399 "boot sweep settled stranded outbox rows for terminal workflows"
400 );
401 }
402 Err(error) => {
403 error!(
404 %error,
405 "boot sweep failed to settle terminal workflows' outbox rows; \
406 the reconciler liveness gate remains the backstop"
407 );
408 }
409 }
410}
411
412/// Spawn the durable-outbox fan-out dispatcher when, and only when, the
413/// operator commissioned it (`outbox.enabled = true`).
414///
415/// This is the single gate that keeps Phase 2 dormant: with the flag off (the
416/// default) the function returns immediately without spawning a task, so
417/// default server behaviour — and the live workflow dispatch path — is entirely
418/// unchanged. When commissioned, the dispatcher claims rows through the engine's
419/// own shared `Arc<LibSqlStore>` (one `libsql::Connection`), so its writes
420/// serialize with the engine's rather than contending across a second
421/// connection. The dispatcher shares the server's shutdown watch, so it drains
422/// on the same signal as the transports.
423///
424/// NOTE (Phase boundary): the spawned dispatcher dispatches claimed rows and
425/// records each row's terminal outbox state (done / retry / failed). Routing the
426/// worker completion back into workflow history through the Recorder is Phase 3
427/// and is not wired here.
428fn maybe_spawn_outbox_dispatcher(
429 state: &ServerState,
430 outbox_config: &OutboxConfig,
431 clustered: bool,
432 backpressure_settings: BackpressureSettings,
433 shutdown_rx: &tokio::sync::watch::Receiver<bool>,
434) -> Result<OutboxWorkerListener, ServerError> {
435 if !outbox_config.enabled {
436 return Ok(OutboxWorkerListener::default());
437 }
438 let dispatcher_config = resolve_outbox_config(outbox_config)?;
439 // Share the engine's already-opened store: one backing connection. The
440 // dispatcher's `claim_outbox_rows` writes then serialize against the engine's
441 // `append_with_outbox` on that single connection instead of contending across
442 // a second one. Both the libSQL and the haematite backends provide an
443 // `OutboxStore` (the haematite leaf is wired as the outbox store at boot); the
444 // in-memory backend has no outbox table, so `outbox_store()` is `None` and
445 // commissioning the dispatcher against it is a configuration error (LSUB-4-2).
446 let outbox_store = state.outbox_store().ok_or_else(|| ServerError::Config {
447 message: "outbox.enabled=true requires store.backend=libsql or store.backend=haematite: \
448 the durable outbox dispatcher claims rows from the store's outbox table, which \
449 the in-memory store does not provide"
450 .to_owned(),
451 })?;
452 let (row_dispatch, worker_listener) = select_outbox_row_dispatch(state, outbox_config)?;
453 // LSUB-2: share the engine's advisory wake so the stage seam pulses this
454 // dispatcher the instant a fan-out row commits, dispatching in ~RTT instead of
455 // up to one poll interval. The wake is always-on and free; the interval poll is
456 // untouched, so it remains the correctness backstop for any lost wake.
457 // Control-Plane Phase 2 (P2-Q2): attach per-tenant keyed backpressure so each
458 // sweep claims per-namespace, round-robin, capped at each tenant's CLAIMED-only
459 // headroom (`per_node_ceiling − claimed`). The quota cache front-runs a per-sweep
460 // quorum `get_namespace`. With the generous platform default and no tenant
461 // override the ceiling never engages, so a default deployment's claim behaviour is
462 // byte-identical to the pre-Phase-2 single unscoped claim.
463 let quota_cache = crate::worker::QuotaCache::new(
464 Arc::clone(state.namespace_store()),
465 backpressure_settings.platform_default,
466 QUOTA_CACHE_TTL,
467 );
468 let backpressure =
469 crate::worker::Backpressure::new(quota_cache.clone(), backpressure_settings.fraction);
470 let mut dispatcher =
471 OutboxDispatcher::new(Arc::clone(&outbox_store), row_dispatch, dispatcher_config)
472 .with_wake(state.outbox_wake())
473 .with_backpressure(backpressure);
474 // #204: attach the engine's durable pause dispatch-hold so a held (paused)
475 // run's rows are never claimed. The hold set is rebuilt from `list_paused`
476 // BEFORE this spawn (see `run_server`), so the dispatcher's first claim
477 // already excludes pre-pause rows after a restart.
478 if let Ok(engine) = state.engine() {
479 dispatcher = dispatcher.with_paused_runs(engine.paused_runs());
480 }
481 tokio::spawn(dispatcher.run(shutdown_rx.clone()));
482 // Control-Plane Phase 2 (P2-Q3): commission the ops-console quota-state
483 // broadcaster on the SAME durable stores + quota cache the dispatcher enforces
484 // against, so the console badge is a faithful window onto the live per-tenant
485 // in-flight/ceiling the backpressure caps. It shares the shutdown watch, so it
486 // drains with the dispatcher. Only spawned alongside the (default-off)
487 // dispatcher: quota state is meaningless without the outbox fan-out path, and
488 // `in_flight` is the durable Claimed outbox count that path produces.
489 let quota_broadcaster = crate::worker::QuotaBroadcaster::new(
490 Arc::clone(state.namespace_store()),
491 Arc::clone(&outbox_store),
492 quota_cache,
493 state.cluster_publisher().clone(),
494 QUOTA_BROADCAST_CADENCE,
495 );
496 tokio::spawn(quota_broadcaster.run(shutdown_rx.clone()));
497 // LSUB-4-1: the single dispatcher task is spawned in both modes. In a
498 // single-node boot it owns all shards by construction; in an active-active
499 // clustered boot it claims ONLY the shards this node owns, enforced by
500 // `claim_outbox_rows`' owned-shard scope (already seeded before this point).
501 info!(
502 clustered,
503 "outbox dispatcher commissioned (active-active per-shard ownership enforced by claim scope \
504 when clustered; single-node owns all shards)"
505 );
506 // LSUB-4-4: the stale-claim reconciler is the in-flight recovery backstop. It
507 // is only configured when BOTH reconcile knobs are set, so on a clustered boot
508 // that left them unset, owner-kill in-flight recovery latency is bounded only
509 // by re-residency replay (a survivor adopting the shard re-residents from
510 // history and re-arms via `rearm_outbox_pending`), NOT by `stale_after`. Warn
511 // so the operator knows the backstop is absent.
512 if let Some(reconciler_config) = resolve_outbox_reconciler_config(outbox_config)? {
513 // #253: the reconciler's liveness gate projects each stale candidate's
514 // workflow status from the engine's event store before any re-arm, so
515 // a terminal workflow's stranded row settles instead of redelivering.
516 let event_store = state.engine()?.store();
517 let reconciler = OutboxReconciler::new(outbox_store, event_store, reconciler_config);
518 tokio::spawn(reconciler.run(shutdown_rx.clone()));
519 info!("outbox reconciler commissioned (terminal-workflow liveness gate active)");
520 } else if clustered {
521 warn!(
522 "outbox reconciler is UNCONFIGURED on a clustered boot (outbox.reconcile_interval_ms \
523 and outbox.reconcile_stale_after_ms are both unset): in-flight recovery after an \
524 owner is killed is then bounded only by re-residency replay on the adopting node, \
525 not by a stale-claim backstop; set both knobs to bound stale-claim recovery latency"
526 );
527 }
528 Ok(worker_listener)
529}
530
531/// Spawn the SS-5b cluster supervisor when, and only when, this is a distributed
532/// haematite boot whose `[store.cluster]` declared peers with owned shards.
533///
534/// Reads the failover cadence + debounce from the cluster config (or the
535/// documented defaults), then asks the state to spawn the supervisor over its
536/// retained concrete store and live engine. With no `[store.cluster]` section —
537/// or with no peer declaring `owned_shards` — nothing is spawned and behaviour
538/// is unchanged.
539#[cfg(feature = "haematite-backend")]
540fn maybe_spawn_cluster_supervisor(
541 state: &ServerState,
542 cluster_config: Option<&crate::config::ClusterConfig>,
543 shutdown_rx: &tokio::sync::watch::Receiver<bool>,
544) -> Result<(), ServerError> {
545 let Some(cluster) = cluster_config else {
546 return Ok(());
547 };
548 let poll_interval = std::time::Duration::from_millis(
549 cluster
550 .failover_poll_interval_ms
551 .unwrap_or(crate::config::DEFAULT_FAILOVER_POLL_INTERVAL_MS),
552 );
553 let confirmations = cluster
554 .failover_confirmations
555 .unwrap_or(crate::config::DEFAULT_FAILOVER_CONFIRMATIONS);
556 let supervisor_config = crate::cluster::SupervisorConfig {
557 poll_interval,
558 confirmations,
559 };
560 let spawned = state.spawn_cluster_supervisor(supervisor_config, shutdown_rx.clone())?;
561 if spawned {
562 info!(
563 poll_interval_ms = %poll_interval.as_millis(),
564 confirmations,
565 "SS-5b cluster supervisor commissioned (automatic peer-down failover)"
566 );
567 }
568 Ok(())
569}
570
571/// Select the outbox row-dispatch sink by the configured `outbox.transport`,
572/// returning the sink plus the worker listener whose lifetime the caller must
573/// hold.
574///
575/// `grpc` (the default) builds the unchanged [`WorkerOutboxDispatch`] over the
576/// connected-worker registry and carries the empty [`OutboxWorkerListener`], so a
577/// default server is byte-identical. `liminal` builds the cross-node
578/// [`RegistryLiminalDispatch`](crate::worker::RegistryLiminalDispatch) AND stands
579/// up the liminal worker listener the aion-server hosts (returned in the guard);
580/// it is only reachable when the `liminal-transport` feature is compiled in, and
581/// selecting it without that feature is a configuration error rather than a
582/// silent fall-through to gRPC.
583fn select_outbox_row_dispatch(
584 state: &ServerState,
585 outbox_config: &OutboxConfig,
586) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
587 match outbox_config.transport {
588 OutboxTransport::Grpc => {
589 let push_dispatcher = ActivityDispatcher::new(state.worker_registry().clone())
590 .with_drain_state(state.drain_state().clone());
591 // Control-Plane Phase 2 (P2-P3): attach the short-TTL placement cache
592 // so an unpinned row in a `Prefer{L}` namespace prefers an L-labelled
593 // worker (spilling to any live worker). The cache front-runs a per-row
594 // quorum `get_namespace` on the hot claim loop; a default-`Unplaced`
595 // deployment is byte-identical (every row falls through to any-worker).
596 let placement_cache = crate::worker::PlacementCache::new(
597 Arc::clone(state.namespace_store()),
598 PLACEMENT_CACHE_TTL,
599 );
600 let dispatch: Arc<dyn OutboxRowDispatch> = Arc::new(
601 WorkerOutboxDispatch::new(push_dispatcher).with_placement_cache(placement_cache),
602 );
603 Ok((dispatch, OutboxWorkerListener::default()))
604 }
605 OutboxTransport::Liminal => build_liminal_row_dispatch(state, outbox_config),
606 }
607}
608
609/// Build the production liminal row-dispatch sink and host the worker listener, or
610/// fail with the missing-feature error.
611///
612/// This lifts the tested cross-node wiring (the `lsub1`/`lsub5` e2e blueprint)
613/// into the production boot. The aion-server HOSTS the liminal listener that
614/// remote workers connect IN to, so its
615/// [`ConnectionSupervisor`](liminal_server::server::connection::ConnectionSupervisor)
616/// owns each worker's connection and can push a dispatch out on it. The
617/// constructor cycle resolves the notifier <-> supervisor dependency:
618///
619/// 1. Reuse the registry already in [`ServerState`] — gRPC and liminal workers
620/// share ONE registry and the same `select_worker`, so routing is identical.
621/// 2. Build the [`LiminalConnectionNotifier`] over that registry (no supervisor
622/// yet).
623/// 3. Build the [`LiminalConnectionServices`] from the liminal listen config.
624/// 4. Build the [`ConnectionSupervisor`] WITH the services + notifier.
625/// 5. Bind the supervisor back into the notifier (must succeed).
626/// 6. Bind the [`ServerListener`] on the configured listen address — workers
627/// connect IN here.
628/// 7. Reuse the SAME completion callback the gRPC completion path installs
629/// ([`ServerOutboxDeliveryCallback`] over the live engine), so a liminal
630/// completion re-enters aion through the identical terminal-recording seam.
631/// 8. Build the [`RegistryLiminalDispatch`] over the registry + callback (it
632/// constructs the [`LiminalCompletionSource`] internally).
633///
634/// The returned listener is held by the caller for the server's lifetime; its
635/// `Drop` stops the accept worker on shutdown.
636///
637/// [`LiminalConnectionServices`]: liminal_server::server::connection::LiminalConnectionServices
638/// [`ServerListener`]: liminal_server::server::listener::ServerListener
639/// [`ServerOutboxDeliveryCallback`]: crate::worker::ServerOutboxDeliveryCallback
640/// [`LiminalCompletionSource`]: crate::worker::LiminalCompletionSource
641/// [`LiminalConnectionNotifier`]: crate::worker::LiminalConnectionNotifier
642#[cfg(feature = "liminal-transport")]
643fn build_liminal_row_dispatch(
644 state: &ServerState,
645 outbox_config: &OutboxConfig,
646) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
647 use liminal_server::config::ServerConfig as LiminalServerConfig;
648 use liminal_server::config::{LimitsConfig, ServicesConfig};
649 use liminal_server::server::connection::{ConnectionSupervisor, LiminalConnectionServices};
650 use liminal_server::server::listener::ServerListener;
651
652 use crate::worker::{
653 LiminalConnectionNotifier, RegistryLiminalDispatch, ServerOutboxDeliveryCallback,
654 };
655
656 let listen_address = outbox_config
657 .liminal_listen_address
658 .as_ref()
659 .ok_or_else(|| ServerError::Config {
660 message: "outbox.transport=liminal requires outbox.liminal_listen_address \
661 (host:port the aion-server listens on for inbound liminal worker \
662 connections)"
663 .to_owned(),
664 })?;
665 let listen_address: SocketAddr =
666 listen_address
667 .parse()
668 .map_err(|error| ServerError::Config {
669 message: format!(
670 "outbox.liminal_listen_address must be a host:port socket address: {error}"
671 ),
672 })?;
673
674 // The liminal listener is the worker-connection front door only: it binds the
675 // wire listen address and serves the connection supervisor. `from_config` and
676 // `ServerListener::bind` read neither `health_listen_address` nor `channels`
677 // (the health probe is bound only by the standalone liminal server's full
678 // boot, not this embedded path), so no separate health port is bound here;
679 // it is set structurally to the listen address and never used.
680 let liminal_config = LiminalServerConfig {
681 listen_address,
682 health_listen_address: listen_address,
683 drain_timeout_ms: 30_000,
684 channels: Vec::new(),
685 routing_rules: Vec::new(),
686 persistence_path: None,
687 cluster: None,
688 // liminal 0.2.3 (H4) added an optional shared-token Connect gate. `None`
689 // keeps this embedded worker front door open at the liminal layer —
690 // identical to the pre-0.2.3 wire behavior; worker identity/authorization
691 // stays aion's job (x-aion-* registration metadata). Threading an
692 // operator-configured token through aion's outbox config is a separate
693 // feature decision, not part of the dependency alignment.
694 auth: None,
695 // liminal 0.2.4 (D2/§5): service profile + operational bounds. Defaults =
696 // full profile + the certifying-pair-signed caps — byte-equivalent to the
697 // 0.2.3 behaviour this embedded front door always had. A worker-front-door
698 // profile election here is a future feature decision, not this migration.
699 services: ServicesConfig::default(),
700 limits: LimitsConfig::default(),
701 // liminal 0.3.0 (LP-WS-TRANSPORT R1 / LP Part B): optional WebSocket
702 // acceptor and participant lifecycle activation. `None` for both starts
703 // no WebSocket listener and leaves the participant capability disabled —
704 // documented as byte-identical to the pre-0.3.0 build. Electing either
705 // for this embedded worker front door is a feature decision, not part of
706 // the dependency alignment.
707 websocket: None,
708 participant: None,
709 };
710
711 // (1) Reuse the registry already in ServerState: gRPC + liminal workers share
712 // ONE registry and the same `select_worker`.
713 let registry = state.worker_registry().clone();
714 // (2) Notifier over that registry (supervisor bound after it is built), with the
715 // NOI-5b transcript tap: a worker's observability publishes on the reserved
716 // channel drain into the SAME transcript sequencer the transcript socket serves,
717 // so a live agent's transcript is persisted + fanned out. (Captures the current
718 // runtime handle to bridge the sync connection callback onto the async append.)
719 let notifier = Arc::new(
720 LiminalConnectionNotifier::new(registry.clone())
721 .with_transcript_publisher(state.transcript_publisher().clone())
722 // The SAME per-task liveness tracker the engine-seam bridge tracks
723 // into: a liminal worker's automatic liveness beats refresh it, so
724 // the #176 expiry sweeper never falsely expires a healthy liminal
725 // worker running an activity longer than the heartbeat window.
726 .with_heartbeat_tracker(state.heartbeat_tracker().clone()),
727 );
728 // (3) Connection services from the liminal listen config.
729 let services = Arc::new(
730 LiminalConnectionServices::from_config(&liminal_config).map_err(|error| {
731 ServerError::Config {
732 message: format!("liminal connection services build failed: {error}"),
733 }
734 })?,
735 );
736 // (4) Supervisor WITH the services + notifier (the cycle's forward edge).
737 let supervisor = ConnectionSupervisor::with_services_and_notifier(services, notifier.clone())
738 .map_err(|error| ServerError::Config {
739 message: format!("liminal connection supervisor build failed: {error}"),
740 })?;
741 // (5) Bind the supervisor back into the notifier (the cycle's back edge); a
742 // failure here is a wiring bug, surfaced rather than silently ignored.
743 if !notifier.bind_supervisor(supervisor.clone()) {
744 return Err(ServerError::Config {
745 message: "liminal notifier supervisor handle was already bound during boot".to_owned(),
746 });
747 }
748 // (6) Bind the listener on the configured address — workers connect IN here.
749 let listener =
750 ServerListener::bind(&liminal_config, supervisor).map_err(|error| ServerError::Config {
751 message: format!("liminal worker listener failed to bind {listen_address}: {error}"),
752 })?;
753 // (7) Reuse the SAME completion callback the gRPC completion path uses, over
754 // the live engine, so a liminal completion re-enters aion through the
755 // identical terminal-recording seam (`record_fan_out_completion`).
756 let engine = state.engine()?;
757 let callback: Arc<dyn crate::worker::OutboxDeliveryCallback> =
758 Arc::new(ServerOutboxDeliveryCallback::new(engine));
759 // (8) The registry-backed dispatch builds its LiminalCompletionSource from the
760 // shared callback internally. Attach the SAME short-TTL placement cache the
761 // gRPC arm installs (Control-Plane Phase 2, P2-P3), so an unpinned row in a
762 // `Prefer{L}` namespace prefers an L-labelled worker (spilling to any live
763 // worker) on the cross-node liminal transport too — the cluster-failover
764 // demo behaviour. A default-`Unplaced` deployment is byte-identical.
765 let placement_cache = crate::worker::PlacementCache::new(
766 Arc::clone(state.namespace_store()),
767 PLACEMENT_CACHE_TTL,
768 );
769 // NOI-6: install the SAME attempt-owner back-index the server's intervention
770 // router resolves through, so each dispatched agent attempt binds its owning
771 // worker and a pushed command reaches the worker this dispatcher sent it to.
772 let dispatch: Arc<dyn OutboxRowDispatch> = Arc::new(
773 RegistryLiminalDispatch::new(registry, callback)
774 .with_placement_cache(placement_cache)
775 .with_attempt_owners(state.attempt_owners().clone()),
776 );
777
778 info!(
779 listen_address = %listen_address,
780 "liminal outbox worker listener commissioned (remote workers connect in and self-register)"
781 );
782 Ok((
783 dispatch,
784 OutboxWorkerListener {
785 _inner: Some(listener),
786 },
787 ))
788}
789
790/// Feature-off stub: selecting the liminal transport without the
791/// `liminal-transport` feature is a configuration error, never a silent
792/// fall-through to gRPC.
793#[cfg(not(feature = "liminal-transport"))]
794fn build_liminal_row_dispatch(
795 _state: &ServerState,
796 _outbox_config: &OutboxConfig,
797) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
798 Err(ServerError::Config {
799 message: "outbox.transport=liminal requires the aion-server `liminal-transport` \
800 Cargo feature, which is not enabled in this build"
801 .to_owned(),
802 })
803}
804
805/// Resolve the validated, all-present outbox knobs into the dispatcher's
806/// non-optional config. Validation already guaranteed each value is set and in
807/// range when `outbox.enabled` is true, so an absent value here is a defensive
808/// configuration error, not a default to invent.
809fn resolve_outbox_config(outbox: &OutboxConfig) -> Result<OutboxDispatcherConfig, ServerError> {
810 let poll_interval_ms = outbox.poll_interval_ms.ok_or_else(|| ServerError::Config {
811 message: crate::config::OUTBOX_POLL_INTERVAL_REQUIRED.to_owned(),
812 })?;
813 let batch_size = outbox.batch_size.ok_or_else(|| ServerError::Config {
814 message: crate::config::OUTBOX_BATCH_SIZE_REQUIRED.to_owned(),
815 })?;
816 let max_attempts = outbox.max_attempts.ok_or_else(|| ServerError::Config {
817 message: crate::config::OUTBOX_MAX_ATTEMPTS_REQUIRED.to_owned(),
818 })?;
819 let backoff_base_ms = outbox.backoff_base_ms.ok_or_else(|| ServerError::Config {
820 message: crate::config::OUTBOX_BACKOFF_BASE_REQUIRED.to_owned(),
821 })?;
822 let backoff_multiplier = outbox
823 .backoff_multiplier
824 .ok_or_else(|| ServerError::Config {
825 message: crate::config::OUTBOX_BACKOFF_MULTIPLIER_REQUIRED.to_owned(),
826 })?;
827 let backoff_max_ms = outbox.backoff_max_ms.ok_or_else(|| ServerError::Config {
828 message: crate::config::OUTBOX_BACKOFF_MAX_REQUIRED.to_owned(),
829 })?;
830 Ok(OutboxDispatcherConfig {
831 poll_interval: std::time::Duration::from_millis(poll_interval_ms),
832 batch_size,
833 max_attempts,
834 backoff_base: std::time::Duration::from_millis(backoff_base_ms),
835 backoff_multiplier,
836 backoff_max: std::time::Duration::from_millis(backoff_max_ms),
837 })
838}
839
840fn resolve_outbox_reconciler_config(
841 outbox: &OutboxConfig,
842) -> Result<Option<OutboxReconcilerConfig>, ServerError> {
843 let (Some(interval_ms), Some(stale_after_ms)) = (
844 outbox.reconcile_interval_ms,
845 outbox.reconcile_stale_after_ms,
846 ) else {
847 return Ok(None);
848 };
849 let batch_size = outbox.batch_size.ok_or_else(|| ServerError::Config {
850 message: crate::config::OUTBOX_BATCH_SIZE_REQUIRED.to_owned(),
851 })?;
852 Ok(Some(OutboxReconcilerConfig {
853 interval: std::time::Duration::from_millis(interval_ms),
854 stale_after: std::time::Duration::from_millis(stale_after_ms),
855 batch_size,
856 }))
857}
858
859fn reject_tls_until_supported(state: &ServerState) -> Result<(), ServerError> {
860 if state.runtime_config().tls.is_some() {
861 return Err(ServerError::Config {
862 message: "configured TLS material cannot be served until transport TLS is wired"
863 .to_owned(),
864 });
865 }
866 Ok(())
867}
868
869fn store_backend_label(backend: StoreBackend) -> &'static str {
870 match backend {
871 StoreBackend::Memory => "memory",
872 StoreBackend::LibSql => "libsql",
873 StoreBackend::Haematite => "haematite",
874 }
875}
876
877fn namespace_mode_label(mode: &NamespaceMode) -> &'static str {
878 match mode {
879 NamespaceMode::SharedEngine => "SharedEngine",
880 NamespaceMode::SingleTenant { .. } => "SingleTenant",
881 }
882}
883
884fn transport_bind<E>(transport: &'static str, address: SocketAddr, source: E) -> ServerError
885where
886 E: std::error::Error,
887{
888 ServerError::TransportBind {
889 transport,
890 address,
891 message: source.to_string(),
892 }
893}
894
895#[cfg(test)]
896mod tests {
897 #![allow(clippy::expect_used)]
898
899 use super::{
900 BackpressureSettings, OutboxConfig, OutboxTransport, maybe_spawn_outbox_dispatcher,
901 resolve_outbox_reconciler_config,
902 };
903 use crate::ServerState;
904 use crate::config::RuntimeConfig;
905 use aion_store::InMemoryStore;
906 use std::net::SocketAddr;
907 use std::time::Duration;
908
909 /// Own-all, generous-default backpressure settings for the gate tests (the
910 /// single-node default: fraction 1, so the ceiling never engages).
911 fn test_backpressure_settings() -> BackpressureSettings {
912 BackpressureSettings {
913 platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
914 fraction: crate::worker::OwnedShardFraction::own_all(),
915 }
916 }
917
918 /// A minimal `RuntimeConfig` for building an in-memory `ServerState` in unit
919 /// tests (mirrors `state.rs`'s test `runtime_config`).
920 fn runtime_config() -> RuntimeConfig {
921 use crate::config::{
922 AuthConfig, AuthoringConfig, DeployConfig, DevConfig, ListenConfig, MetricsConfig,
923 NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig,
924 WebSocketConfig, WorkerConfig,
925 };
926 RuntimeConfig {
927 listen: ListenConfig {
928 grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
929 http: SocketAddr::from(([127, 0, 0, 1], 8080)),
930 },
931 tls: None,
932 auth: AuthConfig {
933 enabled: false,
934 jwks_url: None,
935 jwks_refresh_seconds: 300,
936 },
937 ops_console: OpsConsoleConfig {
938 source: OpsConsoleAssetSource::Embedded,
939 },
940 namespace: NamespaceConfig {
941 mode: NamespaceMode::SharedEngine,
942 },
943 worker: WorkerConfig {
944 heartbeat_window: Duration::from_millis(30_000),
945 },
946 websocket: WebSocketConfig {
947 outbound_buffer_bound: 32,
948 event_broadcast_capacity: Some(64),
949 cluster_broadcast_capacity: Some(64),
950 },
951 workflow_packages: Vec::new(),
952 deploy: DeployConfig::default(),
953 authoring: AuthoringConfig::default(),
954 dev: DevConfig::default(),
955 outbox: OutboxConfig::default(),
956 observability: crate::config::ObservabilityConfig::default(),
957 scheduler_threads: 1,
958 query_timeout: Some(Duration::from_millis(10_000)),
959 default_namespace: "default".to_owned(),
960 auto_create: crate::config::AutoCreate::Open,
961 max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
962 drain_timeout: Duration::from_secs(30),
963 metrics: MetricsConfig { enabled: true },
964 owned_shards: Vec::new(),
965 cors_allowed_origins: Vec::new(),
966 }
967 }
968
969 /// An `OutboxConfig` with `enabled = true` and every required knob present, so
970 /// the only remaining gate is the store-backend / outbox-table availability.
971 fn enabled_outbox_config() -> OutboxConfig {
972 OutboxConfig {
973 enabled: true,
974 poll_interval_ms: Some(250),
975 batch_size: Some(64),
976 max_attempts: Some(5),
977 backoff_base_ms: Some(100),
978 backoff_multiplier: Some(2),
979 backoff_max_ms: Some(30_000),
980 reconcile_interval_ms: None,
981 reconcile_stale_after_ms: None,
982 transport: OutboxTransport::Grpc,
983 liminal_listen_address: None,
984 }
985 }
986
987 /// LSUB-4-2 / LSUB-4-6 (Memory-backend guard): commissioning the outbox
988 /// dispatcher against the in-memory backend (which has no outbox table, so
989 /// `outbox_store()` is `None`) is a configuration error, and the message names
990 /// BOTH supported backends (libsql / haematite), not just libsql.
991 #[tokio::test]
992 async fn outbox_enabled_on_memory_backend_is_a_config_error() {
993 let state = ServerState::build_with_store(InMemoryStore::default(), runtime_config())
994 .await
995 .expect("build in-memory state");
996 let (_tx, rx) = tokio::sync::watch::channel(false);
997 let error = maybe_spawn_outbox_dispatcher(
998 &state,
999 &enabled_outbox_config(),
1000 false,
1001 test_backpressure_settings(),
1002 &rx,
1003 )
1004 .expect_err("outbox.enabled on the memory backend must be a config error");
1005 assert!(
1006 error.is_config(),
1007 "memory-backend outbox error must be Config"
1008 );
1009 let message = error.to_string();
1010 assert!(
1011 message.contains("libsql") && message.contains("haematite"),
1012 "corrected message must name both supported backends, got: {message}"
1013 );
1014 }
1015
1016 /// LSUB-4-1 (Fork-B fast path): with the outbox disabled (the default), the
1017 /// gate is a no-op even on a memory backend — nothing is spawned and no error
1018 /// is produced, so a default single-node boot is unchanged.
1019 #[tokio::test]
1020 async fn disabled_outbox_is_a_noop_on_any_backend() {
1021 let state = ServerState::build_with_store(InMemoryStore::default(), runtime_config())
1022 .await
1023 .expect("build in-memory state");
1024 let (_tx, rx) = tokio::sync::watch::channel(false);
1025 maybe_spawn_outbox_dispatcher(
1026 &state,
1027 &OutboxConfig::default(),
1028 false,
1029 test_backpressure_settings(),
1030 &rx,
1031 )
1032 .expect("disabled outbox gate must be an infallible no-op");
1033 }
1034
1035 /// LSUB-4-4: the reconciler config resolves to `None` unless BOTH knobs are
1036 /// set — the condition under which the clustered-boot WARN fires.
1037 #[test]
1038 fn reconciler_config_absent_unless_both_knobs_set() {
1039 let mut config = enabled_outbox_config();
1040 // Neither knob: absent.
1041 assert!(
1042 resolve_outbox_reconciler_config(&config)
1043 .expect("resolve")
1044 .is_none()
1045 );
1046 // Only interval: still absent (the silent-backstop-absent default).
1047 config.reconcile_interval_ms = Some(1_000);
1048 assert!(
1049 resolve_outbox_reconciler_config(&config)
1050 .expect("resolve")
1051 .is_none()
1052 );
1053 // Both set: present.
1054 config.reconcile_stale_after_ms = Some(60_000);
1055 assert!(
1056 resolve_outbox_reconciler_config(&config)
1057 .expect("resolve")
1058 .is_some()
1059 );
1060 }
1061
1062 /// LSUB-PROD (13-6): the liminal transport requires `liminal_listen_address`.
1063 /// Commissioning the dispatcher with `transport = liminal` but no listen
1064 /// address is a configuration error naming the missing knob, rather than a
1065 /// panic or a silent fall-through to gRPC. Built over the libSQL backend (so
1066 /// the outbox-store gate passes and the missing-address check is actually
1067 /// reached). (Feature-gated: the liminal arm of `build_liminal_row_dispatch`
1068 /// only exists with `liminal-transport` on; in a feature-off build the same
1069 /// selection is the missing-feature error instead, covered by the type system
1070 /// rather than this test.)
1071 // Also gated on `libsql-backend`: it boots a real libSQL-backed `ServerState`
1072 // to obtain an outbox-bearing store, and the libSQL connect path is now an
1073 // opt-in feature. The listen-address guard itself is backend-agnostic.
1074 #[cfg(all(feature = "liminal-transport", feature = "libsql-backend"))]
1075 #[tokio::test]
1076 async fn liminal_transport_requires_listen_address() {
1077 use crate::config::{
1078 RuntimeSection, ServerConfig, StoreBackend, StoreConfig, WebSocketConfig,
1079 };
1080
1081 let db_path = std::env::temp_dir().join(format!(
1082 "aion-lsub-prod-listen-guard-{}-{}.db",
1083 std::process::id(),
1084 std::time::SystemTime::now()
1085 .duration_since(std::time::UNIX_EPOCH)
1086 .map(|elapsed| elapsed.as_nanos())
1087 .unwrap_or_default()
1088 ));
1089 let mut outbox = enabled_outbox_config();
1090 outbox.transport = OutboxTransport::Liminal;
1091 outbox.liminal_listen_address = None;
1092 let config = ServerConfig {
1093 store: StoreConfig {
1094 backend: StoreBackend::LibSql,
1095 url: Some(db_path.to_string_lossy().into_owned()),
1096 ..StoreConfig::default()
1097 },
1098 runtime: RuntimeSection {
1099 scheduler_threads: 1,
1100 query_timeout_ms: Some(10_000),
1101 },
1102 websocket: WebSocketConfig {
1103 outbound_buffer_bound: 32,
1104 event_broadcast_capacity: Some(64),
1105 cluster_broadcast_capacity: Some(64),
1106 },
1107 outbox: outbox.clone(),
1108 ..ServerConfig::default()
1109 };
1110 let state = ServerState::build(config)
1111 .await
1112 .expect("build libsql state");
1113 let (_tx, rx) = tokio::sync::watch::channel(false);
1114
1115 let error = maybe_spawn_outbox_dispatcher(
1116 &state,
1117 &outbox,
1118 false,
1119 test_backpressure_settings(),
1120 &rx,
1121 )
1122 .expect_err("liminal transport without a listen address must be a config error");
1123 assert!(
1124 error.is_config(),
1125 "missing-listen-address error must be Config"
1126 );
1127 assert!(
1128 error.to_string().contains("liminal_listen_address"),
1129 "error must name the missing knob, got: {error}"
1130 );
1131 }
1132}
1133
1134/// LSUB-PROD (13-6): production-boot cross-node round-trip over the REAL wiring.
1135///
1136/// This is the proof that the production boot now does the full round-trip the
1137/// retired stub could not. It drives the EXACT production commissioning function
1138/// `run_server` calls — [`maybe_spawn_outbox_dispatcher`] — over a real
1139/// [`ServerState`] built with `outbox.enabled`, `transport = liminal`, and a
1140/// `liminal_listen_address`. That function lifts the full push wiring
1141/// (`build_liminal_row_dispatch`): it hosts the liminal worker listener, builds
1142/// [`RegistryLiminalDispatch`](crate::worker::RegistryLiminalDispatch) over the
1143/// SAME registry the gRPC path uses and the SAME
1144/// [`ServerOutboxDeliveryCallback`](crate::worker::ServerOutboxDeliveryCallback)
1145/// (over the live engine), and spawns the real [`OutboxDispatcher`].
1146///
1147/// A REAL remote [`LiminalActivityWorker`](aion_worker::LiminalActivityWorker)
1148/// connects IN to the listener and self-registers in-band. A `collect_four`
1149/// fan-out is started over the REAL HTTP transport, which stages four pending
1150/// outbox rows; the production-wired dispatcher claims and pushes each to the
1151/// worker, the worker executes it, and its completion re-enters aion through the
1152/// production engine callback — `record_fan_out_completion` — driving the
1153/// workflow to a recorded terminal. The proof asserts BOTH: the worker observably
1154/// executed the activities, AND the terminals were recorded in history (four
1155/// `ActivityCompleted` + one `WorkflowCompleted`), which the stub's
1156/// publish-and-mark-done path never achieved.
1157// Also gated on `libsql-backend`: this production-boot round-trip stands up a
1158// real libSQL-backed server (the durable outbox path it exercises), and the
1159// libSQL connect path is now an opt-in feature.
1160#[cfg(all(test, feature = "liminal-transport", feature = "libsql-backend"))]
1161mod lsub_prod_xnode_e2e {
1162 #![allow(clippy::expect_used)]
1163
1164 use std::net::SocketAddr;
1165 use std::path::PathBuf;
1166 use std::sync::Arc;
1167 use std::sync::atomic::{AtomicUsize, Ordering};
1168 use std::time::{Duration, Instant};
1169
1170 use aion_core::Event;
1171 use aion_package::{
1172 BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest, ManifestVersion,
1173 PackageBuilder,
1174 };
1175 use aion_store::ReadableEventStore;
1176 use aion_store_libsql::LibSqlStore;
1177 use aion_worker::{ActivityRegistry, LiminalActivityWorker, WorkerConfig};
1178 use axum::body;
1179 use axum::http::{Request, StatusCode};
1180 use serde_json::json;
1181 use tower::ServiceExt;
1182
1183 use super::{BackpressureSettings, maybe_spawn_outbox_dispatcher};
1184 use crate::ServerState;
1185 use crate::api::http::http_router;
1186 use crate::config::{
1187 OutboxConfig, OutboxTransport, RuntimeSection, ServerConfig, StoreBackend, StoreConfig,
1188 WebSocketConfig,
1189 };
1190
1191 type TestError = Box<dyn std::error::Error + Send + Sync>;
1192
1193 /// The `collect_four` fixture passes each member the JSON string `"in"` as
1194 /// activity input, so the worker handler decodes a [`String`], not a struct.
1195 type FanInput = String;
1196
1197 const NAMESPACE: &str = "default";
1198 const TASK_QUEUE: &str = "default";
1199 const OUTBOX_MODULE: &str = "aion_outbox_fixture";
1200 const OUTBOX_BEAM: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.beam");
1201 const OUTBOX_SOURCE: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.erl");
1202 const FAN_OUT: usize = 4;
1203 const FAN_ACTIVITY_TYPES: [&str; FAN_OUT] = ["fan:0", "fan:1", "fan:2", "fan:3"];
1204 const POLL_DEADLINE: Duration = Duration::from_secs(20);
1205
1206 fn test_error(message: impl std::fmt::Display) -> TestError {
1207 message.to_string().into()
1208 }
1209
1210 /// Reserve a loopback port and return it: the liminal listener binds this exact
1211 /// address (the production path binds the configured `liminal_listen_address`,
1212 /// so the test must commit to a concrete port the worker can also dial).
1213 fn reserve_loopback_port() -> Result<SocketAddr, TestError> {
1214 let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(test_error)?;
1215 let address = listener.local_addr().map_err(test_error)?;
1216 drop(listener);
1217 Ok(address)
1218 }
1219
1220 /// Build the `collect_four` package on disk so the production state-build path
1221 /// loads it exactly as it loads operator-supplied `workflow_packages`.
1222 fn write_package_archive(dir: &std::path::Path) -> Result<PathBuf, TestError> {
1223 let beams =
1224 BeamSet::new(vec![BeamModule::new(OUTBOX_MODULE, OUTBOX_BEAM)]).map_err(test_error)?;
1225 let manifest = Manifest {
1226 entry_module: OUTBOX_MODULE.to_owned(),
1227 entry_function: "collect_four".to_owned(),
1228 input_schema: json!({ "type": "object" }),
1229 output_schema: json!({}),
1230 timeout: Duration::from_secs(30),
1231 activities: vec![DeclaredActivity {
1232 activity_type: "fixture_activity".to_owned(),
1233 }],
1234 version: ManifestVersion::new("stamped-by-builder"),
1235 format_version: CURRENT_FORMAT_VERSION,
1236 additional_workflows: Vec::new(),
1237 };
1238 let archive =
1239 PackageBuilder::with_source(manifest, beams, [(OUTBOX_MODULE, OUTBOX_SOURCE.to_vec())])
1240 .write_to_bytes()
1241 .map_err(test_error)?;
1242 let path = dir.join("collect_four.aion");
1243 std::fs::write(&path, archive).map_err(test_error)?;
1244 Ok(path)
1245 }
1246
1247 /// A production-shaped `ServerConfig`: the libSQL backend (so the boot store
1248 /// path shares the leaf as the dispatcher's outbox store, exactly as
1249 /// `ServerState::build` does in production), `outbox.enabled`,
1250 /// `transport = liminal`, the reserved `liminal_listen_address`, and the
1251 /// `collect_four` package. Built through `ServerState::build` (not
1252 /// `build_with_store`), so this is the real boot store seam, not a test stand-in.
1253 fn server_config(
1254 db_path: &std::path::Path,
1255 package_path: PathBuf,
1256 listen_address: SocketAddr,
1257 ) -> ServerConfig {
1258 ServerConfig {
1259 store: StoreConfig {
1260 backend: StoreBackend::LibSql,
1261 url: Some(db_path.to_string_lossy().into_owned()),
1262 ..StoreConfig::default()
1263 },
1264 runtime: RuntimeSection {
1265 scheduler_threads: 1,
1266 query_timeout_ms: Some(10_000),
1267 },
1268 websocket: WebSocketConfig {
1269 outbound_buffer_bound: 32,
1270 event_broadcast_capacity: Some(64),
1271 cluster_broadcast_capacity: Some(64),
1272 },
1273 workflow_packages: vec![package_path],
1274 outbox: OutboxConfig {
1275 enabled: true,
1276 poll_interval_ms: Some(20),
1277 batch_size: Some(16),
1278 max_attempts: Some(5),
1279 backoff_base_ms: Some(50),
1280 backoff_multiplier: Some(2),
1281 backoff_max_ms: Some(1_000),
1282 reconcile_interval_ms: None,
1283 reconcile_stale_after_ms: None,
1284 transport: OutboxTransport::Liminal,
1285 liminal_listen_address: Some(listen_address.to_string()),
1286 },
1287 ..ServerConfig::default()
1288 }
1289 }
1290
1291 /// The remote worker self-describes for the fixture's pool `(default, default)`
1292 /// and registers a handler for every `fan:N` activity type, counting executions
1293 /// so the test proves it genuinely ran the pushed dispatches.
1294 fn worker_config() -> Result<WorkerConfig, TestError> {
1295 WorkerConfig::builder()
1296 .endpoint("unused-direct-address")
1297 .namespace(NAMESPACE)
1298 .task_queue(TASK_QUEUE)
1299 .identity("lsub-prod-worker")
1300 .max_concurrency(4)
1301 .reconnect_initial_backoff(Duration::from_millis(5))
1302 .reconnect_max_backoff(Duration::from_millis(20))
1303 .reconnect_max_attempts(3)
1304 .build()
1305 .map_err(test_error)
1306 }
1307
1308 fn worker_registry(executions: &Arc<AtomicUsize>) -> Result<Arc<ActivityRegistry>, TestError> {
1309 let mut registry = ActivityRegistry::new();
1310 for activity_type in FAN_ACTIVITY_TYPES {
1311 let executions = Arc::clone(executions);
1312 registry = registry
1313 .register_activity(activity_type, move |_input: FanInput, _context| {
1314 let executions = Arc::clone(&executions);
1315 Box::pin(async move {
1316 executions.fetch_add(1, Ordering::SeqCst);
1317 Ok(activity_type.to_owned())
1318 })
1319 })
1320 .map_err(test_error)?;
1321 }
1322 Ok(Arc::new(registry))
1323 }
1324
1325 /// Spawns the remote worker on its own OS thread with a current-thread runtime
1326 /// (the push receive is blocking), connecting IN to the production listener.
1327 struct WorkerThread {
1328 stop: Arc<std::sync::atomic::AtomicBool>,
1329 handle: Option<std::thread::JoinHandle<()>>,
1330 }
1331
1332 impl WorkerThread {
1333 fn spawn(address: String, config: WorkerConfig, registry: Arc<ActivityRegistry>) -> Self {
1334 let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1335 let thread_stop = Arc::clone(&stop);
1336 let handle = std::thread::spawn(move || {
1337 let runtime = match tokio::runtime::Builder::new_current_thread()
1338 .enable_all()
1339 .build()
1340 {
1341 Ok(runtime) => runtime,
1342 Err(error) => {
1343 eprintln!("worker runtime build failed: {error}");
1344 return;
1345 }
1346 };
1347 runtime.block_on(async move {
1348 let worker = match LiminalActivityWorker::connect(&address, &config, registry) {
1349 Ok(worker) => worker,
1350 Err(error) => {
1351 eprintln!("worker connect failed: {error}");
1352 return;
1353 }
1354 };
1355 if let Err(error) = worker
1356 .serve_until(|| thread_stop.load(Ordering::SeqCst))
1357 .await
1358 {
1359 eprintln!("worker serve loop ended with error: {error}");
1360 }
1361 });
1362 });
1363 Self {
1364 stop,
1365 handle: Some(handle),
1366 }
1367 }
1368
1369 fn stop(mut self) {
1370 self.stop.store(true, Ordering::SeqCst);
1371 if let Some(handle) = self.handle.take() {
1372 handle.join().ok();
1373 }
1374 }
1375 }
1376
1377 fn count_completed(history: &[Event]) -> usize {
1378 history
1379 .iter()
1380 .filter(|event| matches!(event, Event::ActivityCompleted { .. }))
1381 .count()
1382 }
1383
1384 fn count_workflow_completed(history: &[Event]) -> usize {
1385 history
1386 .iter()
1387 .filter(|event| matches!(event, Event::WorkflowCompleted { .. }))
1388 .count()
1389 }
1390
1391 async fn wait_for_history<F>(
1392 store: &LibSqlStore,
1393 workflow_id: &aion_core::WorkflowId,
1394 description: &str,
1395 predicate: F,
1396 ) -> Result<Vec<Event>, TestError>
1397 where
1398 F: Fn(&[Event]) -> bool,
1399 {
1400 let deadline = Instant::now() + POLL_DEADLINE;
1401 loop {
1402 let history = store.read_history(workflow_id).await.map_err(test_error)?;
1403 if predicate(&history) {
1404 return Ok(history);
1405 }
1406 if Instant::now() > deadline {
1407 return Err(test_error(format!(
1408 "timed out waiting for {description}: {history:#?}"
1409 )));
1410 }
1411 tokio::time::sleep(Duration::from_millis(25)).await;
1412 }
1413 }
1414
1415 /// Start the loaded `collect_four` workflow over the REAL HTTP transport.
1416 async fn start_over_http(router: &axum::Router) -> Result<aion_core::WorkflowId, TestError> {
1417 let build_request = || -> Result<Request<body::Body>, TestError> {
1418 Request::builder()
1419 .uri("/workflows/start")
1420 .method("POST")
1421 .header("content-type", "application/json")
1422 .header("x-aion-subject", "ci")
1423 .header("x-aion-namespaces", NAMESPACE)
1424 .body(body::Body::from(
1425 serde_json::to_vec(&json!({
1426 "namespace": NAMESPACE,
1427 "workflow_type": OUTBOX_MODULE,
1428 "input": { "fixture": "input" },
1429 }))
1430 .map_err(test_error)?,
1431 ))
1432 .map_err(test_error)
1433 };
1434 let response = router
1435 .clone()
1436 .oneshot(build_request()?)
1437 .await
1438 .map_err(test_error)?;
1439 let status = response.status();
1440 let bytes = body::to_bytes(response.into_body(), usize::MAX)
1441 .await
1442 .map_err(test_error)?
1443 .to_vec();
1444 if status != StatusCode::OK {
1445 return Err(test_error(format!(
1446 "workflow start over HTTP must succeed, got {status}: {}",
1447 String::from_utf8_lossy(&bytes)
1448 )));
1449 }
1450 let body: serde_json::Value = serde_json::from_slice(&bytes).map_err(test_error)?;
1451 // The HTTP wire contract (`clean_dtos::StartWorkflowResponse`) serializes
1452 // `workflow_id` as a plain UUID string, not a nested `{ uuid }` object.
1453 let workflow_id = body["workflow_id"]
1454 .as_str()
1455 .ok_or_else(|| test_error("start response missing workflow id"))?
1456 .parse::<uuid::Uuid>()
1457 .map_err(test_error)?;
1458 Ok(aion_core::WorkflowId::new(workflow_id))
1459 }
1460
1461 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1462 async fn production_boot_dispatches_executes_and_records_over_liminal() -> Result<(), TestError>
1463 {
1464 let dir = tempfile::tempdir().map_err(test_error)?;
1465 let db_path = dir.path().join("aion.db");
1466 let package_path = write_package_archive(dir.path())?;
1467 // The production path binds the CONFIGURED listen address, so commit to a
1468 // concrete reserved loopback port the worker can also dial.
1469 let listen_address = reserve_loopback_port()?;
1470
1471 // (A) Build a real ServerState through the production boot path
1472 // (ServerState::build over a libSQL ServerConfig): outbox enabled,
1473 // transport = liminal, the listen address set, collect_four loaded. This
1474 // shares the libSQL leaf as the dispatcher's outbox store (the real boot
1475 // store seam) and installs the production ServerOutboxDeliveryCallback over
1476 // the live engine (gated on outbox.enabled).
1477 let config = server_config(&db_path, package_path, listen_address);
1478 let outbox_config = config.outbox.clone();
1479 let state = ServerState::build(config).await.map_err(test_error)?;
1480
1481 // (B) Drive the EXACT production commissioning function run_server calls:
1482 // it hosts the liminal listener, builds RegistryLiminalDispatch over the
1483 // shared registry + engine callback, and spawns the real OutboxDispatcher.
1484 // Hold the returned listener guard for the test's lifetime, exactly as
1485 // run_server holds it.
1486 let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
1487 // Own-all, generous-default backpressure (single-node e2e): fraction 1 and
1488 // the platform default, so the ceiling never engages — the claim behaves
1489 // exactly as before, proving the production path is byte-identical on default.
1490 let backpressure_settings = BackpressureSettings {
1491 platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
1492 fraction: crate::worker::OwnedShardFraction::own_all(),
1493 };
1494 let listener_guard = maybe_spawn_outbox_dispatcher(
1495 &state,
1496 &outbox_config,
1497 false,
1498 backpressure_settings,
1499 &shutdown_rx,
1500 )
1501 .map_err(test_error)?;
1502
1503 // (C) A REAL remote worker connects IN to the production listener and
1504 // self-registers in-band for the fixture's pool.
1505 let executions = Arc::new(AtomicUsize::new(0));
1506 let worker = WorkerThread::spawn(
1507 listen_address.to_string(),
1508 worker_config()?,
1509 worker_registry(&executions)?,
1510 );
1511
1512 // Wait until the in-band registration landed in the SAME registry the
1513 // dispatch path selects from (every fan-out activity type is eligible).
1514 let registry = state.worker_registry().clone();
1515 let deadline = Instant::now() + Duration::from_secs(5);
1516 loop {
1517 let ready = FAN_ACTIVITY_TYPES.iter().all(|activity_type| {
1518 registry
1519 .select_worker(NAMESPACE, TASK_QUEUE, activity_type, None)
1520 .ok()
1521 .flatten()
1522 .is_some()
1523 });
1524 if ready {
1525 break;
1526 }
1527 if Instant::now() > deadline {
1528 worker.stop();
1529 return Err(test_error("worker never registered in-band for the pool"));
1530 }
1531 tokio::time::sleep(Duration::from_millis(10)).await;
1532 }
1533
1534 // (D) Start collect_four over the REAL HTTP transport: the engine stages
1535 // four pending outbox rows; the production-wired dispatcher claims and
1536 // pushes each to the worker.
1537 let router = http_router(state.clone()).map_err(test_error)?;
1538 let workflow_id = start_over_http(&router).await?;
1539
1540 // (E) THE PROOF: the worker executed all four activities AND every terminal
1541 // was recorded through the production engine callback (record_fan_out_completion)
1542 // — four ActivityCompleted + one WorkflowCompleted in durable history. This
1543 // is the full round-trip the retired stub never achieved.
1544 let reader = LibSqlStore::open(db_path.clone())
1545 .await
1546 .map_err(test_error)?;
1547 let settled = wait_for_history(&reader, &workflow_id, "fan-out settled", |events| {
1548 count_completed(events) == FAN_OUT && count_workflow_completed(events) == 1
1549 })
1550 .await?;
1551 assert_eq!(
1552 count_completed(&settled),
1553 FAN_OUT,
1554 "every fan-out member must record a terminal through the production callback"
1555 );
1556 assert_eq!(
1557 count_workflow_completed(&settled),
1558 1,
1559 "the workflow must complete exactly once"
1560 );
1561 assert_eq!(
1562 executions.load(Ordering::SeqCst),
1563 FAN_OUT,
1564 "the remote worker must have executed every pushed dispatch exactly once"
1565 );
1566
1567 // Teardown: stop the dispatcher + worker, drop the listener guard (its Drop
1568 // stops the accept worker), shut the engine down so durable appends finish.
1569 shutdown_tx.send(true).ok();
1570 worker.stop();
1571 drop(listener_guard);
1572 state.shutdown().map_err(test_error)?;
1573 Ok(())
1574 }
1575}