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