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
134/// The where-to-edit half of the missing `outbox.liminal_listen_address`
135/// refusal: a liminal outbox refusal must name the FILE to edit, not just the
136/// key — the operator reading it is exactly the operator who did not write
137/// the config (a scaffolded or setup-script home).
138fn liminal_address_hint(source: &crate::config::ConfigSource) -> String {
139 match source {
140 crate::config::ConfigSource::BuiltInDefaults => {
141 "set AION_OUTBOX_LIMINAL_LISTEN_ADDRESS, or add `liminal_listen_address = \
142 \"127.0.0.1:50061\"` to `[outbox]` in a config file"
143 .to_owned()
144 }
145 source => format!(
146 "add `liminal_listen_address = \"127.0.0.1:50061\"` to `[outbox]` in the {source}"
147 ),
148 }
149}
150
151async fn run_server(cli: CliOverrides) -> Result<ExitCode, ServerError> {
152 observability::tracing::init()?;
153
154 // #180: a boot that discovers no config anywhere first scaffolds
155 // `<AION_HOME>/config.toml` from the embedded template (claim-only-when-
156 // empty), then loads it — config LOAD itself stays pure and read-only.
157 let loaded = crate::config::load_or_scaffold(&cli)?;
158 loaded.resolution.ensure_private_home()?;
159 // Arm the death note as early as the home exists, so every later failure
160 // path — including config validation and state build — runs inside the
161 // ARMED/DISARMED bracket. Two anonymous server deaths on 2026-08-16 are
162 // why this exists; see the module docs for the exact coverage.
163 let death_note = crate::death_note::DeathNote::arm(&loaded.resolution.home)?;
164 loaded.resolution.log_startup();
165 let liminal_address_hint = liminal_address_hint(&loaded.resolution.source);
166 let config = loaded.config;
167 reject_auth_without_feature(&config)?;
168 let store_backend = config.store.backend;
169 // Static shard assignment (SS-1): read the operator's pinned shard set from
170 // `[store] owned_shards`. Empty means own ALL shards (single-node default).
171 // The set is carried into `RuntimeConfig` by `into_parts` and applied to the
172 // `EngineBuilder` during state construction; surface it here so the boot
173 // banner records which shards this node serves. No election is performed.
174 let owned_shards = config.store.owned_shards.clone();
175 // Capture the outbox settings before `build` consumes `config`, so the
176 // (default-off) outbox dispatcher can be wired after state is up. The
177 // dispatcher shares the engine's already-opened haematite store via
178 // `state.outbox_store()`, so no store settings are needed.
179 let outbox_config = config.outbox.clone();
180 // Control-Plane Phase 2 (P2-Q2): capture the keyed-backpressure inputs — the
181 // generous platform-default ceiling and this node's owned-shard fraction —
182 // before `build` consumes `config`. On a single-node / own-all boot the fraction
183 // is 1, so per-node ceilings equal the cluster-wide quota and, with the generous
184 // default and no tenant override, the ceiling never engages (byte-identical claim).
185 let backpressure_settings = BackpressureSettings::from_config(&config);
186 // Capture the SS-5b failover supervisor knobs before `build` consumes config.
187 // Only a distributed haematite boot carries a `[store.cluster]` section; this
188 // is `None` for every single-node boot, so no supervisor is ever spawned.
189 let cluster_config = config.store.cluster.clone();
190 // Capture the managed-worker supervision policy before `build` consumes
191 // `config`. Resolution already happened during config validation, so this
192 // cannot surprise an operator at boot; it is re-read here because the
193 // policy is COMMISSIONED onto the supervisor built into state below, and a
194 // server without the section supervises nothing.
195 let supervision_policy = config.worker_supervision.resolve()?;
196 let state = ServerState::build(config).await?;
197 reject_tls_until_supported(&state)?;
198
199 let runtime = state.runtime_config();
200 let grpc_address = runtime.listen.grpc;
201 let http_address = runtime.listen.http;
202 let workflow_packages: Vec<String> = runtime
203 .workflow_packages
204 .iter()
205 .map(|path| path.display().to_string())
206 .collect();
207 // The revision, not just the version. A crate version cannot distinguish
208 // two builds from different commits of the same version, and that is the
209 // distinction an operator needs when deciding whether a restart restores
210 // what was running or substitutes something else (#123). The endpoint
211 // answers this too, but a crashed server leaves only its log.
212 let build = crate::build_identity::BuildIdentity::current();
213 // #139: the server-resolved workspace root (the aion home's `clones/`
214 // directory) that declared bodies expand `{workspace_root}` with. Reported
215 // here so composition points (setup.sh today, the workspace verb later)
216 // READ the value from the server that will use it instead of re-deriving
217 // it. An unresolvable root is reported as exactly that — never fabricated;
218 // a placeholder-bearing dispatch will refuse terminally with this reason.
219 // The rendering itself is `WorkspaceRoot::banner_value`, pinned by its own
220 // two-case test, so the banner and the tests cannot drift apart.
221 let workspace_root = state.workspace_root().banner_value();
222 info!(
223 version = env!("CARGO_PKG_VERSION"),
224 build = %build.line(),
225 commit = build.commit,
226 grpc_address = %grpc_address,
227 http_address = %http_address,
228 default_namespace = %runtime.default_namespace,
229 namespace_mode = namespace_mode_label(&runtime.namespace.mode),
230 store_backend = store_backend_label(store_backend),
231 auth_enabled = runtime.auth.enabled,
232 deploy_enabled = runtime.deploy.enabled,
233 metrics_enabled = runtime.metrics.enabled,
234 workspace_root = %workspace_root,
235 death_note = %death_note.path().display(),
236 workflow_package_count = workflow_packages.len(),
237 workflow_packages = ?workflow_packages,
238 owned_shards = ?owned_shards,
239 owns_all_shards = owned_shards.is_empty(),
240 "aion-server startup banner"
241 );
242 // #139 leg C: the assistant ships IN aion. The embedded document is
243 // installed here — after the engine has reloaded every persisted package,
244 // so the install can see what is already resident, and before the
245 // transports accept traffic, so the first caller finds it. It claims only a
246 // catalog holding no version of the assistant type; anything else is the
247 // operator's cut to make, and the outcome says so in the log either way.
248 crate::assistant::install_embedded_assistant_for_server(&state, &liminal_address_hint).await;
249 // #189 slice one: the built-in update check ships the same way, under the
250 // same only-the-empty-case install rule. Installing makes it STARTABLE
251 // and nothing else — no check runs without an explicit operator act.
252 crate::update_check::install_embedded_update_check_for_server(&state).await;
253 let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
254 // LSUB-4-1: a distributed haematite boot carries a `[store.cluster]` section.
255 // The single outbox dispatcher task is spawned in BOTH modes; the difference
256 // is only how ownership is enforced. Single-node (`None`) owns all shards by
257 // construction (`owned_shard_scope() == None`), so its claim sweeps see every
258 // row. Clustered (`Some`) relies on `claim_outbox_rows`' `owned_shard_scope()`
259 // filter — already seeded by `set_owned_shards` during `ServerState::build`,
260 // which runs before this point — so each node only ever claims rows on the
261 // shards it owns. Compute the flag here where the cluster section is in
262 // scope; pass it to the gate so the boot banner records the mode.
263 let outbox_clustered = cluster_config.is_some();
264 // Dormant by default: only when `outbox.enabled` is set does the
265 // non-replayed outbox dispatcher task start. With the flag off (the
266 // default) nothing here runs and server behaviour is unchanged.
267 // Hold the liminal worker listener (if any) for the server's lifetime: it is
268 // dropped at the end of `run_server`, after the serve `select!` completes, so
269 // its accept worker stops cleanly on shutdown via the listener's own `Drop`.
270 // #204/#253: rebuild the pause dispatch-hold and settle terminal
271 // workflows' stranded outbox rows BEFORE the dispatcher's first claim.
272 rebuild_outbox_boot_state(&state, &outbox_config).await;
273 let _outbox_worker_listener = maybe_spawn_outbox_dispatcher(
274 &state,
275 &outbox_config,
276 outbox_clustered,
277 backpressure_settings,
278 &shutdown_rx,
279 &liminal_address_hint,
280 )?;
281 // SS-5b: a distributed boot whose peers declare owned shards runs the cluster
282 // supervisor — automatic failover detection. A single-node boot spawns
283 // nothing here (the method returns `false`), so default behaviour is
284 // unchanged.
285 maybe_spawn_cluster_supervisor(&state, cluster_config.as_ref(), &shutdown_rx)?;
286 // #176: the worker heartbeat expiry sweeper is ALWAYS commissioned —
287 // dead-worker detection is a liveness correctness property, not an opt-in
288 // feature. It is the production caller of `fail_expired_workers`: a worker
289 // whose stream stays open while its process wedges (stops heartbeating
290 // without disconnecting) is expired, deregistered with the provable Timeout
291 // reason, and its in-flight tasks surface as TRANSPORT losses, re-dispatched
292 // attempt-neutrally rather than charged to the action's retry budget.
293 // Cadence derives from `worker.heartbeat_window` (quarter-window, clamped to
294 // [1s, window]; the default 30s window sweeps every 7.5s) — deliberately no
295 // separate config knob. It drains on the same shutdown watch as the
296 // transports; dropping the JoinHandle only detaches the task.
297 drop(state.spawn_heartbeat_sweeper(shutdown_rx.clone()));
298 commission_worker_supervision(&state, supervision_policy).await;
299 let mut grpc = tokio::spawn(serve_grpc(state.clone(), grpc_address, shutdown_rx.clone()));
300 let mut http = tokio::spawn(serve_http(state.clone(), http_address, shutdown_rx));
301
302 let outcome = tokio::select! {
303 result = &mut grpc => {
304 transport_result("gRPC", result)?;
305 state.shutdown()?;
306 ShutdownOutcome::Clean
307 },
308 result = &mut http => {
309 transport_result("HTTP", result)?;
310 state.shutdown()?;
311 ShutdownOutcome::Clean
312 },
313 result = shutdown_signal() => {
314 result?;
315 let _receiver_count = shutdown_tx.send(true);
316 let outcome = shutdown::drain_after_first_signal(state.clone(), async {
317 let _ = shutdown_signal().await;
318 }).await?;
319 if !matches!(outcome, ShutdownOutcome::Forced) {
320 transport_result("gRPC", grpc.await)?;
321 transport_result("HTTP", http.await)?;
322 }
323 outcome
324 },
325 };
326
327 let exit_code = outcome.exit_code();
328 death_note.disarm(&format!(
329 "clean run-loop exit: shutdown outcome {outcome:?}"
330 ));
331 Ok(exit_code)
332}
333
334/// Install the operator's supervision policy and converge the fleet.
335///
336/// Uncommissioned is a first-class but never SILENT state: a server with no
337/// `[worker_supervision]` section supervises nothing, and every deployment that
338/// wanted to be running is named in the warning, so the gap between "the
339/// operator deployed a worker" and "nothing is running it" is never quiet.
340async fn commission_worker_supervision(
341 state: &ServerState,
342 policy: Option<crate::worker::SupervisionPolicy>,
343) {
344 let supervisor = state.worker_supervisor();
345 let Some(policy) = policy else {
346 match supervisor.report().await {
347 Ok(report) => {
348 let wanted: Vec<&str> = report
349 .workers
350 .iter()
351 .filter(|worker| worker.desired == aion_store::DesiredState::Running)
352 .map(|worker| worker.name.as_str())
353 .collect();
354 if wanted.is_empty() {
355 info!("managed-worker supervision is not configured; no deployment wants it");
356 } else {
357 warn!(
358 deployments = wanted.join(", "),
359 remedy = crate::worker::supervisor::UNCOMMISSIONED_REMEDY,
360 "worker deployments want to be running but supervision is not configured"
361 );
362 }
363 }
364 Err(error) => error!(
365 %error,
366 "managed-worker supervision is not configured and the deployment records \
367 could not be read to say what that costs"
368 ),
369 }
370 return;
371 };
372 if !supervisor.commission(policy, crate::worker::ManagedExecutable::CurrentServer) {
373 error!("managed-worker supervision was already commissioned before boot completed");
374 return;
375 }
376 match supervisor.reconcile().await {
377 Ok(0) => info!("managed-worker supervision commissioned; no deployment wants to run"),
378 Ok(supervised) => info!(supervised, "managed-worker supervision commissioned"),
379 Err(error) => error!(%error, "managed-worker fleet could not be converged at boot"),
380 }
381}
382
383fn transport_result(
384 transport: &'static str,
385 result: Result<Result<(), ServerError>, tokio::task::JoinError>,
386) -> Result<(), ServerError> {
387 match result {
388 Ok(transport_outcome) => transport_outcome,
389 Err(join_error) => Err(ServerError::Transport {
390 transport,
391 message: join_error.to_string(),
392 }),
393 }
394}
395
396async fn serve_grpc(
397 state: ServerState,
398 address: SocketAddr,
399 shutdown: tokio::sync::watch::Receiver<bool>,
400) -> Result<(), ServerError> {
401 let workflow = api::grpc::workflow_service(state.clone());
402 let worker = api::worker_grpc::worker_service(state.clone());
403 let mut router = TonicServer::builder()
404 .add_service(workflow)
405 .add_service(worker);
406 // Dark by default: the deploy service joins the listener only when the
407 // operator commissioned it; otherwise the surface answers Unimplemented.
408 if state.runtime_config().deploy.enabled {
409 router = router.add_service(api::deploy_grpc::deploy_service(state)?);
410 }
411 router
412 .serve_with_shutdown(address, shutdown_requested(shutdown))
413 .await
414 .map_err(|source| transport_bind("grpc", address, source))?;
415 Ok(())
416}
417
418async fn serve_http(
419 state: ServerState,
420 address: SocketAddr,
421 shutdown: tokio::sync::watch::Receiver<bool>,
422) -> Result<(), ServerError> {
423 let listener = TcpListener::bind(address)
424 .await
425 .map_err(|source| transport_bind("http", address, source))?;
426 axum::serve(listener, api::http::http_router(state)?)
427 .with_graceful_shutdown(shutdown_requested(shutdown))
428 .await
429 .map_err(|source| transport_bind("http", address, source))?;
430 Ok(())
431}
432
433async fn shutdown_requested(mut shutdown: tokio::sync::watch::Receiver<bool>) {
434 while !*shutdown.borrow_and_update() {
435 if shutdown.changed().await.is_err() {
436 break;
437 }
438 }
439}
440
441async fn shutdown_signal() -> Result<(), ServerError> {
442 #[cfg(unix)]
443 {
444 use tokio::signal::unix::{SignalKind, signal};
445
446 let mut terminate = signal(SignalKind::terminate())
447 .map_err(|source| signal_listener("SIGTERM", &source))?;
448 let mut interrupt =
449 signal(SignalKind::interrupt()).map_err(|source| signal_listener("SIGINT", &source))?;
450 tokio::select! {
451 _ = terminate.recv() => Ok(()),
452 _ = interrupt.recv() => Ok(()),
453 }
454 }
455
456 #[cfg(not(unix))]
457 {
458 tokio::signal::ctrl_c()
459 .await
460 .map_err(|source| signal_listener("shutdown signal", &source))
461 }
462}
463
464fn signal_listener(listener: &'static str, source: &std::io::Error) -> ServerError {
465 ServerError::SignalListener {
466 listener,
467 message: source.to_string(),
468 }
469}
470
471fn reject_auth_without_feature(config: &ServerConfig) -> Result<(), ServerError> {
472 if cfg!(not(feature = "auth")) && config.auth.enabled {
473 return Err(ServerError::Config {
474 message: "auth.enabled=true but binary compiled without auth feature".to_owned(),
475 });
476 }
477 Ok(())
478}
479
480/// Rebuild the outbox-related boot state BEFORE the dispatcher's first claim,
481/// when (and only when) the outbox is commissioned:
482///
483/// - #204: repopulate the durable pause dispatch-hold from `list_paused`, so a
484/// run paused before a restart keeps its outbox rows held (never claimed)
485/// after recovery. A run projecting `Paused` is excluded from `list_active`
486/// respawn for free; this repopulates the hold that would otherwise be empty
487/// in memory after a crash.
488/// - #253: settle terminal workflows' stranded outbox rows. A workflow that
489/// reached a durable terminal without its rows being settled (a settle-hook
490/// failure, or a crash between the terminal append and the settle) must not
491/// have those rows re-armed and redelivered after restart — that is the
492/// zombie-round incident. A sweep error is loud but non-fatal: the
493/// settle-at-terminal hook and the reconciler's liveness gate remain as
494/// repair paths, and the residual window is one bounded dispatch whose
495/// completion drops unmatched, never a re-arm loop.
496async fn rebuild_outbox_boot_state(state: &ServerState, outbox_config: &OutboxConfig) {
497 if !outbox_config.enabled {
498 return;
499 }
500 let Ok(engine) = state.engine() else {
501 return;
502 };
503 if let Err(error) = engine.rebuild_paused_runs().await {
504 warn!(%error, "failed to rebuild paused-runs dispatch hold at startup");
505 }
506 let Some(outbox_store) = state.outbox_store() else {
507 return;
508 };
509 match crate::worker::settle_terminal_outbox_rows(engine.store().as_ref(), outbox_store.as_ref())
510 .await
511 {
512 Ok(settled) if settled.is_empty() => {}
513 Ok(settled) => {
514 info!(
515 settled = settled.len(),
516 "boot sweep settled stranded outbox rows for terminal workflows"
517 );
518 }
519 Err(error) => {
520 error!(
521 %error,
522 "boot sweep failed to settle terminal workflows' outbox rows; \
523 the reconciler liveness gate remains the backstop"
524 );
525 }
526 }
527}
528
529/// Spawn the durable-outbox fan-out dispatcher when, and only when, the
530/// operator commissioned it (`outbox.enabled = true`).
531///
532/// This is the single gate that keeps Phase 2 dormant: with the flag off (the
533/// default) the function returns immediately without spawning a task, so
534/// default server behaviour — and the live workflow dispatch path — is entirely
535/// unchanged. When commissioned, the dispatcher claims rows through the engine's
536/// own shared haematite leaf, so its writes serialize through the same durable
537/// store. The dispatcher shares the server's shutdown watch, so it drains on the
538/// same signal as the transports.
539///
540/// NOTE (Phase boundary): the spawned dispatcher dispatches claimed rows and
541/// records each row's terminal outbox state (done / retry / failed). Routing the
542/// worker completion back into workflow history through the Recorder is Phase 3
543/// and is not wired here.
544fn maybe_spawn_outbox_dispatcher(
545 state: &ServerState,
546 outbox_config: &OutboxConfig,
547 clustered: bool,
548 backpressure_settings: BackpressureSettings,
549 shutdown_rx: &tokio::sync::watch::Receiver<bool>,
550 liminal_address_hint: &str,
551) -> Result<OutboxWorkerListener, ServerError> {
552 if !outbox_config.enabled {
553 return Ok(OutboxWorkerListener::default());
554 }
555 let dispatcher_config = resolve_outbox_config(outbox_config)?;
556 // Share the engine's already-opened haematite store. The
557 // dispatcher's `claim_outbox_rows` writes serialize against the engine's
558 // `append_with_outbox`; the
559 // in-memory backend has no outbox table, so `outbox_store()` is `None` and
560 // commissioning the dispatcher against it is a configuration error (LSUB-4-2).
561 let outbox_store = state.outbox_store().ok_or_else(|| ServerError::Config {
562 message: "outbox.enabled=true requires store.backend=haematite: \
563 the durable outbox dispatcher claims rows from the store's outbox table, which \
564 the in-memory store does not provide"
565 .to_owned(),
566 })?;
567 let dispatcher_builder = OutboxDispatcher::new(Arc::clone(&outbox_store), dispatcher_config);
568 let delivery_gate = dispatcher_builder.delivery_gate();
569 let engine = state.engine()?;
570 let delivery_callback: Arc<dyn OutboxDeliveryCallback> =
571 Arc::new(ServerOutboxDeliveryCallback::new(engine));
572 let (row_dispatch, worker_listener) = select_outbox_row_dispatch(
573 state,
574 outbox_config,
575 shutdown_rx,
576 delivery_gate.clone(),
577 Arc::clone(&delivery_callback),
578 liminal_address_hint,
579 )?;
580 // LSUB-2: share the engine's advisory wake so the stage seam pulses this
581 // dispatcher the instant a fan-out row commits, dispatching in ~RTT instead of
582 // up to one poll interval. The wake is always-on and free; the interval poll is
583 // untouched, so it remains the correctness backstop for any lost wake.
584 // Control-Plane Phase 2 (P2-Q2): attach per-tenant keyed backpressure so each
585 // sweep claims per-namespace, round-robin, capped at each tenant's CLAIMED-only
586 // headroom (`per_node_ceiling − claimed`). The quota cache front-runs a per-sweep
587 // quorum `get_namespace`. With the generous platform default and no tenant
588 // override the ceiling never engages, so a default deployment's claim behaviour is
589 // byte-identical to the pre-Phase-2 single unscoped claim.
590 let quota_cache = crate::worker::QuotaCache::new(
591 Arc::clone(state.namespace_store()),
592 backpressure_settings.platform_default,
593 QUOTA_CACHE_TTL,
594 );
595 let backpressure =
596 crate::worker::Backpressure::new(quota_cache.clone(), backpressure_settings.fraction);
597 let mut dispatcher = dispatcher_builder
598 .with_dispatch(row_dispatch)
599 .with_delivery_callback(delivery_callback)
600 .with_wake(state.outbox_wake())
601 .with_backpressure(backpressure);
602 // #204: attach the engine's durable pause dispatch-hold so a held (paused)
603 // run's rows are never claimed. The hold set is rebuilt from `list_paused`
604 // BEFORE this spawn (see `run_server`), so the dispatcher's first claim
605 // already excludes pre-pause rows after a restart.
606 if let Ok(engine) = state.engine() {
607 dispatcher = dispatcher.with_paused_runs(engine.paused_runs());
608 }
609 tokio::spawn(dispatcher.run(shutdown_rx.clone()));
610 // Control-Plane Phase 2 (P2-Q3): commission the ops-console quota-state
611 // broadcaster on the SAME durable stores + quota cache the dispatcher enforces
612 // against, so the console badge is a faithful window onto the live per-tenant
613 // in-flight/ceiling the backpressure caps. It shares the shutdown watch, so it
614 // drains with the dispatcher. Only spawned alongside the (default-off)
615 // dispatcher: quota state is meaningless without the outbox fan-out path, and
616 // `in_flight` is the durable Claimed outbox count that path produces.
617 let quota_broadcaster = crate::worker::QuotaBroadcaster::new(
618 Arc::clone(state.namespace_store()),
619 Arc::clone(&outbox_store),
620 quota_cache,
621 state.cluster_publisher().clone(),
622 QUOTA_BROADCAST_CADENCE,
623 );
624 tokio::spawn(quota_broadcaster.run(shutdown_rx.clone()));
625 // LSUB-4-1: the single dispatcher task is spawned in both modes. In a
626 // single-node boot it owns all shards by construction; in an active-active
627 // clustered boot it claims ONLY the shards this node owns, enforced by
628 // `claim_outbox_rows`' owned-shard scope (already seeded before this point).
629 info!(
630 clustered,
631 "outbox dispatcher commissioned (active-active per-shard ownership enforced by claim scope \
632 when clustered; single-node owns all shards)"
633 );
634 // LSUB-4-4: the stale-claim reconciler is the in-flight recovery backstop. It
635 // is only configured when BOTH reconcile knobs are set, so on a clustered boot
636 // that left them unset, owner-kill in-flight recovery latency is bounded only
637 // by re-residency replay (a survivor adopting the shard re-residents from
638 // history and re-arms via `rearm_outbox_pending`), NOT by `stale_after`. Warn
639 // so the operator knows the backstop is absent.
640 if let Some(reconciler_config) = resolve_outbox_reconciler_config(outbox_config)? {
641 // #253: the reconciler's liveness gate projects each stale candidate's
642 // workflow status from the engine's event store before any re-arm, so
643 // a terminal workflow's stranded row settles instead of redelivering.
644 let event_store = state.engine()?.store();
645 let reconciler = OutboxReconciler::new(outbox_store, event_store, reconciler_config)
646 .with_delivery_gate(delivery_gate);
647 tokio::spawn(reconciler.run(shutdown_rx.clone()));
648 info!("outbox reconciler commissioned (terminal-workflow liveness gate active)");
649 } else if clustered {
650 warn!(
651 "outbox reconciler is UNCONFIGURED on a clustered boot (outbox.reconcile_interval_ms \
652 and outbox.reconcile_stale_after_ms are both unset): in-flight recovery after an \
653 owner is killed is then bounded only by re-residency replay on the adopting node, \
654 not by a stale-claim backstop; set both knobs to bound stale-claim recovery latency"
655 );
656 }
657 Ok(worker_listener)
658}
659
660/// Spawn the SS-5b cluster supervisor when, and only when, this is a distributed
661/// haematite boot whose `[store.cluster]` declared peers with owned shards.
662///
663/// Reads the failover cadence + debounce from the cluster config (or the
664/// documented defaults), then asks the state to spawn the supervisor over its
665/// retained concrete store and live engine. With no `[store.cluster]` section —
666/// or with no peer declaring `owned_shards` — nothing is spawned and behaviour
667/// is unchanged.
668fn maybe_spawn_cluster_supervisor(
669 state: &ServerState,
670 cluster_config: Option<&crate::config::ClusterConfig>,
671 shutdown_rx: &tokio::sync::watch::Receiver<bool>,
672) -> Result<(), ServerError> {
673 let Some(cluster) = cluster_config else {
674 return Ok(());
675 };
676 let poll_interval = std::time::Duration::from_millis(
677 cluster
678 .failover_poll_interval_ms
679 .unwrap_or(crate::config::DEFAULT_FAILOVER_POLL_INTERVAL_MS),
680 );
681 let confirmations = cluster
682 .failover_confirmations
683 .unwrap_or(crate::config::DEFAULT_FAILOVER_CONFIRMATIONS);
684 let supervisor_config = crate::cluster::SupervisorConfig {
685 poll_interval,
686 confirmations,
687 };
688 let spawned = state.spawn_cluster_supervisor(supervisor_config, shutdown_rx.clone())?;
689 if spawned {
690 info!(
691 poll_interval_ms = %poll_interval.as_millis(),
692 confirmations,
693 "SS-5b cluster supervisor commissioned (automatic peer-down failover)"
694 );
695 }
696 Ok(())
697}
698
699/// Select the outbox row-dispatch sink by the configured `outbox.transport`,
700/// returning the sink plus the worker listener whose lifetime the caller must
701/// hold.
702///
703/// `grpc` (the default) builds the unchanged [`WorkerOutboxDispatch`] over the
704/// connected-worker registry and carries the empty [`OutboxWorkerListener`], so a
705/// default server is byte-identical. `liminal` builds the cross-node
706/// [`RegistryLiminalDispatch`](crate::worker::RegistryLiminalDispatch) AND stands
707/// up the liminal worker listener the aion-server hosts (returned in the guard);
708/// it is only reachable when the `liminal-transport` feature is compiled in, and
709/// selecting it without that feature is a configuration error rather than a
710/// silent fall-through to gRPC.
711fn select_outbox_row_dispatch(
712 state: &ServerState,
713 outbox_config: &OutboxConfig,
714 shutdown_rx: &tokio::sync::watch::Receiver<bool>,
715 delivery_gate: DeliveryGate,
716 delivery_callback: Arc<dyn OutboxDeliveryCallback>,
717 liminal_address_hint: &str,
718) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
719 match outbox_config.transport {
720 OutboxTransport::Grpc => {
721 let push_dispatcher = ActivityDispatcher::new(state.worker_registry().clone())
722 .with_drain_state(state.drain_state().clone())
723 .with_completion_fences(state.pending_activities().completion_fences())
724 // Share the SAME queue-service seams the direct dispatch path
725 // uses, so a row parked on this leg reaches `GET
726 // /queues/unserved` and `describe`'s `unserved` list rather
727 // than being invisible to both.
728 .with_queue_service(
729 state.queue_declarations().clone(),
730 state.queue_service_state().clone(),
731 state.runtime_config().worker.queue_service.clone(),
732 )
733 // ...including the cluster publisher, so an unbounded park on
734 // this leg is announced on the operator's real-time channel
735 // exactly like the direct path (#266 T4).
736 .with_cluster_publisher(state.cluster_publisher().clone());
737 // Control-Plane Phase 2 (P2-P3): attach the short-TTL placement cache
738 // so an unpinned row in a `Prefer{L}` namespace prefers an L-labelled
739 // worker (spilling to any live worker). The cache front-runs a per-row
740 // quorum `get_namespace` on the hot claim loop; a default-`Unplaced`
741 // deployment is byte-identical (every row falls through to any-worker).
742 let placement_cache = crate::worker::PlacementCache::new(
743 Arc::clone(state.namespace_store()),
744 PLACEMENT_CACHE_TTL,
745 );
746 let dispatch: Arc<dyn OutboxRowDispatch> = Arc::new(
747 WorkerOutboxDispatch::new(push_dispatcher).with_placement_cache(placement_cache),
748 );
749 Ok((dispatch, OutboxWorkerListener::default()))
750 }
751 OutboxTransport::Liminal => build_liminal_row_dispatch(
752 state,
753 outbox_config,
754 shutdown_rx,
755 delivery_gate,
756 delivery_callback,
757 liminal_address_hint,
758 ),
759 }
760}
761
762/// Build the production liminal row-dispatch sink and host the worker listener, or
763/// fail with the missing-feature error.
764///
765/// This lifts the tested cross-node wiring (the `lsub1`/`lsub5` e2e blueprint)
766/// into the production boot. The aion-server HOSTS the liminal listener that
767/// remote workers connect IN to, so its
768/// [`ConnectionSupervisor`](liminal_server::server::connection::ConnectionSupervisor)
769/// owns each worker's connection and can push a dispatch out on it. The
770/// constructor cycle resolves the notifier <-> supervisor dependency:
771///
772/// 1. Reuse the registry already in [`ServerState`] — gRPC and liminal workers
773/// share ONE registry and the same `select_worker`, so routing is identical.
774/// 2. Build the [`LiminalConnectionNotifier`] over that registry (no supervisor
775/// yet).
776/// 3. Build the [`LiminalConnectionServices`] from the liminal listen config.
777/// 4. Build the [`ConnectionSupervisor`] WITH the services + notifier.
778/// 5. Bind the supervisor back into the notifier (must succeed).
779/// 6. Bind the [`ServerListener`] on the configured listen address — workers
780/// connect IN here.
781/// 7. Reuse the SAME completion callback the gRPC completion path installs
782/// ([`ServerOutboxDeliveryCallback`] over the live engine), so a liminal
783/// completion re-enters aion through the identical terminal-recording seam.
784/// 8. Build the [`RegistryLiminalDispatch`] over the registry + callback (it
785/// constructs the [`LiminalCompletionSource`] internally).
786///
787/// The returned listener is held by the caller for the server's lifetime; its
788/// `Drop` stops the accept worker on shutdown.
789///
790/// [`LiminalConnectionServices`]: liminal_server::server::connection::LiminalConnectionServices
791/// [`ServerListener`]: liminal_server::server::listener::ServerListener
792/// [`ServerOutboxDeliveryCallback`]: crate::worker::ServerOutboxDeliveryCallback
793/// [`LiminalCompletionSource`]: crate::worker::LiminalCompletionSource
794/// [`LiminalConnectionNotifier`]: crate::worker::LiminalConnectionNotifier
795#[cfg(feature = "liminal-transport")]
796fn build_liminal_row_dispatch(
797 state: &ServerState,
798 outbox_config: &OutboxConfig,
799 shutdown_rx: &tokio::sync::watch::Receiver<bool>,
800 delivery_gate: DeliveryGate,
801 callback: Arc<dyn OutboxDeliveryCallback>,
802 liminal_address_hint: &str,
803) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
804 use liminal_server::config::ServerConfig as LiminalServerConfig;
805 use liminal_server::config::{LimitsConfig, ServicesConfig};
806 use liminal_server::server::connection::{ConnectionSupervisor, LiminalConnectionServices};
807 use liminal_server::server::listener::ServerListener;
808
809 use crate::worker::{LiminalConnectionNotifier, RegistryLiminalDispatch};
810
811 let listen_address = outbox_config
812 .liminal_listen_address
813 .as_ref()
814 .ok_or_else(|| ServerError::Config {
815 message: format!(
816 "outbox.transport=liminal requires outbox.liminal_listen_address (host:port \
817 the aion-server listens on for inbound liminal worker connections); \
818 {liminal_address_hint}"
819 ),
820 })?;
821 let listen_address: SocketAddr =
822 listen_address
823 .parse()
824 .map_err(|error| ServerError::Config {
825 message: format!(
826 "outbox.liminal_listen_address must be a host:port socket address: {error}"
827 ),
828 })?;
829
830 // The liminal listener is the worker-connection front door only: it binds the
831 // wire listen address and serves the connection supervisor. `from_config` and
832 // `ServerListener::bind` read neither `health_listen_address` nor `channels`
833 // (the health probe is bound only by the standalone liminal server's full
834 // boot, not this embedded path), so no separate health port is bound here;
835 // it is set structurally to the listen address and never used.
836 let liminal_config = LiminalServerConfig {
837 listen_address,
838 health_listen_address: listen_address,
839 drain_timeout_ms: 30_000,
840 channels: Vec::new(),
841 routing_rules: Vec::new(),
842 persistence_path: None,
843 cluster: None,
844 // liminal 0.2.3 (H4) added an optional shared-token Connect gate. `None`
845 // keeps this embedded worker front door open at the liminal layer —
846 // identical to the pre-0.2.3 wire behavior; worker identity/authorization
847 // stays aion's job (x-aion-* registration metadata). Threading an
848 // operator-configured token through aion's outbox config is a separate
849 // feature decision, not part of the dependency alignment.
850 auth: None,
851 // liminal 0.2.4 (D2/§5): service profile + operational bounds. Defaults =
852 // full profile + the certifying-pair-signed caps — byte-equivalent to the
853 // 0.2.3 behaviour this embedded front door always had. A worker-front-door
854 // profile election here is a future feature decision, not this migration.
855 services: ServicesConfig::default(),
856 limits: LimitsConfig::default(),
857 // liminal 0.3.0 (LP-WS-TRANSPORT R1 / LP Part B): optional WebSocket
858 // acceptor and participant lifecycle activation. `None` for both starts
859 // no WebSocket listener and leaves the participant capability disabled —
860 // documented as byte-identical to the pre-0.3.0 build. Electing either
861 // for this embedded worker front door is a feature decision, not part of
862 // the dependency alignment.
863 websocket: None,
864 participant: None,
865 };
866
867 // (1) Reuse the registry already in ServerState: gRPC + liminal workers share
868 // ONE registry and the same `select_worker`.
869 let registry = state.worker_registry().clone();
870 // (2) Notifier over that registry (supervisor bound after it is built), with the
871 // NOI-5b transcript tap: a worker's observability publishes on the reserved
872 // channel drain into the SAME transcript sequencer the transcript socket serves,
873 // so a live agent's transcript is persisted + fanned out. (Captures the current
874 // runtime handle to bridge the sync connection callback onto the async append.)
875 let notifier = Arc::new(
876 LiminalConnectionNotifier::new(registry.clone())
877 .with_contract_catalog(state.engine()?)
878 .with_transcript_publisher(state.transcript_publisher().clone())
879 // The SAME per-task liveness tracker the engine-seam bridge tracks
880 // into: a liminal worker's automatic liveness beats refresh it, so
881 // the #176 expiry sweeper never falsely expires a healthy liminal
882 // worker running an activity longer than the heartbeat window.
883 .with_heartbeat_tracker(state.heartbeat_tracker().clone()),
884 );
885 // (3) Connection services from the liminal listen config.
886 let services = Arc::new(
887 LiminalConnectionServices::from_config(&liminal_config).map_err(|error| {
888 ServerError::Config {
889 message: format!("liminal connection services build failed: {error}"),
890 }
891 })?,
892 );
893 // (4) Supervisor WITH the services + notifier (the cycle's forward edge).
894 let supervisor = ConnectionSupervisor::with_services_and_notifier(services, notifier.clone())
895 .map_err(|error| ServerError::Config {
896 message: format!("liminal connection supervisor build failed: {error}"),
897 })?;
898 // (5) Bind the supervisor back into the notifier (the cycle's back edge); a
899 // failure here is a wiring bug, surfaced rather than silently ignored.
900 if !notifier.bind_supervisor(supervisor.clone()) {
901 return Err(ServerError::Config {
902 message: "liminal notifier supervisor handle was already bound during boot".to_owned(),
903 });
904 }
905 // (5b) Commission the connection dead-man switch over the SAME notifier. It
906 // pings every connected worker on a derived quarter-window cadence: the
907 // answers keep a healthy IDLE connection's lease alive (so the idle expiry
908 // cannot fire on a live worker), and the pings themselves are what a worker
909 // measures silence against (so a wedged half-open socket becomes a declared,
910 // logged death on the worker side instead of an unbounded blind wait). Not
911 // opt-in: liveness detection is a correctness property of this transport.
912 // The handle is detached — dropping a tokio `JoinHandle` never cancels the
913 // task — exactly as the heartbeat sweeper is spawned.
914 drop(state.spawn_liminal_liveness_probe(notifier.clone(), shutdown_rx.clone()));
915 // (6) Bind the listener on the configured address — workers connect IN here.
916 let listener =
917 ServerListener::bind(&liminal_config, supervisor).map_err(|error| ServerError::Config {
918 message: format!("liminal worker listener failed to bind {listen_address}: {error}"),
919 })?;
920 // (7) Reuse the SAME completion callback the gRPC completion path uses, over
921 // the live engine, so a liminal completion re-enters aion through the
922 // identical terminal-recording seam (`record_fan_out_completion`).
923 // (8) The registry-backed dispatch builds its LiminalCompletionSource from the
924 // shared callback internally. Attach the SAME short-TTL placement cache the
925 // gRPC arm installs (Control-Plane Phase 2, P2-P3), so an unpinned row in a
926 // `Prefer{L}` namespace prefers an L-labelled worker (spilling to any live
927 // worker) on the cross-node liminal transport too — the cluster-failover
928 // demo behaviour. A default-`Unplaced` deployment is byte-identical.
929 let placement_cache = crate::worker::PlacementCache::new(
930 Arc::clone(state.namespace_store()),
931 PLACEMENT_CACHE_TTL,
932 );
933 // NOI-6: install the SAME attempt-owner back-index the server's intervention
934 // router resolves through, so each dispatched agent attempt binds its owning
935 // worker and a pushed command reaches the worker this dispatcher sent it to.
936 let dispatch: Arc<dyn OutboxRowDispatch> = Arc::new(
937 RegistryLiminalDispatch::new(registry, callback, delivery_gate)
938 .with_placement_cache(placement_cache)
939 .with_attempt_owners(state.attempt_owners().clone()),
940 );
941
942 info!(
943 listen_address = %listen_address,
944 "liminal outbox worker listener commissioned (remote workers connect in and self-register)"
945 );
946 Ok((
947 dispatch,
948 OutboxWorkerListener {
949 _inner: Some(listener),
950 },
951 ))
952}
953
954/// Feature-off stub: selecting the liminal transport without the
955/// `liminal-transport` feature is a configuration error, never a silent
956/// fall-through to gRPC.
957#[cfg(not(feature = "liminal-transport"))]
958fn build_liminal_row_dispatch(
959 _state: &ServerState,
960 _outbox_config: &OutboxConfig,
961 _shutdown_rx: &tokio::sync::watch::Receiver<bool>,
962 _delivery_gate: DeliveryGate,
963 _delivery_callback: Arc<dyn OutboxDeliveryCallback>,
964 _liminal_address_hint: &str,
965) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
966 Err(ServerError::Config {
967 message: "outbox.transport=liminal requires the aion-server `liminal-transport` \
968 Cargo feature, which is not enabled in this build"
969 .to_owned(),
970 })
971}
972
973/// Resolve the validated, all-present outbox knobs into the dispatcher's
974/// non-optional config. Validation already guaranteed each value is set and in
975/// range when `outbox.enabled` is true, so an absent value here is a defensive
976/// configuration error, not a default to invent.
977fn resolve_outbox_config(outbox: &OutboxConfig) -> Result<OutboxDispatcherConfig, ServerError> {
978 let poll_interval_ms = outbox.poll_interval_ms.ok_or_else(|| ServerError::Config {
979 message: crate::config::OUTBOX_POLL_INTERVAL_REQUIRED.to_owned(),
980 })?;
981 let batch_size = outbox.batch_size.ok_or_else(|| ServerError::Config {
982 message: crate::config::OUTBOX_BATCH_SIZE_REQUIRED.to_owned(),
983 })?;
984 let max_attempts = outbox.max_attempts.ok_or_else(|| ServerError::Config {
985 message: crate::config::OUTBOX_MAX_ATTEMPTS_REQUIRED.to_owned(),
986 })?;
987 let backoff_base_ms = outbox.backoff_base_ms.ok_or_else(|| ServerError::Config {
988 message: crate::config::OUTBOX_BACKOFF_BASE_REQUIRED.to_owned(),
989 })?;
990 let backoff_multiplier = outbox
991 .backoff_multiplier
992 .ok_or_else(|| ServerError::Config {
993 message: crate::config::OUTBOX_BACKOFF_MULTIPLIER_REQUIRED.to_owned(),
994 })?;
995 let backoff_max_ms = outbox.backoff_max_ms.ok_or_else(|| ServerError::Config {
996 message: crate::config::OUTBOX_BACKOFF_MAX_REQUIRED.to_owned(),
997 })?;
998 Ok(OutboxDispatcherConfig {
999 poll_interval: std::time::Duration::from_millis(poll_interval_ms),
1000 batch_size,
1001 max_attempts,
1002 backoff_base: std::time::Duration::from_millis(backoff_base_ms),
1003 backoff_multiplier,
1004 backoff_max: std::time::Duration::from_millis(backoff_max_ms),
1005 })
1006}
1007
1008fn resolve_outbox_reconciler_config(
1009 outbox: &OutboxConfig,
1010) -> Result<Option<OutboxReconcilerConfig>, ServerError> {
1011 let (Some(interval_ms), Some(stale_after_ms)) = (
1012 outbox.reconcile_interval_ms,
1013 outbox.reconcile_stale_after_ms,
1014 ) else {
1015 return Ok(None);
1016 };
1017 let batch_size = outbox.batch_size.ok_or_else(|| ServerError::Config {
1018 message: crate::config::OUTBOX_BATCH_SIZE_REQUIRED.to_owned(),
1019 })?;
1020 Ok(Some(OutboxReconcilerConfig {
1021 interval: std::time::Duration::from_millis(interval_ms),
1022 stale_after: std::time::Duration::from_millis(stale_after_ms),
1023 batch_size,
1024 }))
1025}
1026
1027fn reject_tls_until_supported(state: &ServerState) -> Result<(), ServerError> {
1028 if state.runtime_config().tls.is_some() {
1029 return Err(ServerError::Config {
1030 message: "configured TLS material cannot be served until transport TLS is wired"
1031 .to_owned(),
1032 });
1033 }
1034 Ok(())
1035}
1036
1037fn store_backend_label(backend: StoreBackend) -> &'static str {
1038 match backend {
1039 StoreBackend::Memory => "memory",
1040 StoreBackend::Haematite => "haematite",
1041 }
1042}
1043
1044fn namespace_mode_label(mode: &NamespaceMode) -> &'static str {
1045 match mode {
1046 NamespaceMode::SharedEngine => "SharedEngine",
1047 NamespaceMode::SingleTenant { .. } => "SingleTenant",
1048 }
1049}
1050
1051fn transport_bind<E>(transport: &'static str, address: SocketAddr, source: E) -> ServerError
1052where
1053 E: std::error::Error,
1054{
1055 ServerError::TransportBind {
1056 transport,
1057 address,
1058 message: source.to_string(),
1059 }
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064 #![allow(clippy::expect_used)]
1065
1066 use super::{
1067 BackpressureSettings, OutboxConfig, OutboxTransport, maybe_spawn_outbox_dispatcher,
1068 resolve_outbox_reconciler_config,
1069 };
1070 use crate::ServerState;
1071 use crate::config::RuntimeConfig;
1072 use aion_store::InMemoryStore;
1073 use std::net::SocketAddr;
1074 use std::time::Duration;
1075
1076 /// Own-all, generous-default backpressure settings for the gate tests (the
1077 /// single-node default: fraction 1, so the ceiling never engages).
1078 fn test_backpressure_settings() -> BackpressureSettings {
1079 BackpressureSettings {
1080 platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
1081 fraction: crate::worker::OwnedShardFraction::own_all(),
1082 }
1083 }
1084
1085 /// A minimal `RuntimeConfig` for building an in-memory `ServerState` in unit
1086 /// tests (mirrors `state.rs`'s test `runtime_config`).
1087 fn runtime_config() -> RuntimeConfig {
1088 use crate::config::{
1089 AuthConfig, AuthoringConfig, DeployConfig, DevConfig, ListenConfig, MetricsConfig,
1090 NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig,
1091 WebSocketConfig, WorkerConfig,
1092 };
1093 RuntimeConfig {
1094 listen: ListenConfig {
1095 grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
1096 http: SocketAddr::from(([127, 0, 0, 1], 8080)),
1097 },
1098 tls: None,
1099 auth: AuthConfig {
1100 enabled: false,
1101 jwks_url: None,
1102 jwks_refresh_seconds: 300,
1103 },
1104 ops_console: OpsConsoleConfig {
1105 source: OpsConsoleAssetSource::Embedded,
1106 },
1107 namespace: NamespaceConfig {
1108 mode: NamespaceMode::SharedEngine,
1109 },
1110 worker: WorkerConfig {
1111 heartbeat_window: Duration::from_secs(30),
1112 ..WorkerConfig::default()
1113 },
1114 websocket: WebSocketConfig {
1115 outbound_buffer_bound: 32,
1116 event_broadcast_capacity: Some(64),
1117 cluster_broadcast_capacity: Some(64),
1118 },
1119 workflow_packages: Vec::new(),
1120 deploy: DeployConfig::default(),
1121 authoring: AuthoringConfig::default(),
1122 dev: DevConfig::default(),
1123 outbox: OutboxConfig::default(),
1124 observability: crate::config::ObservabilityConfig::with_flush_policy(64, 0),
1125 mcp: crate::config::ResolvedMcpConfig::default(),
1126 scheduler_threads: 1,
1127 jit_threshold: None,
1128 query_timeout: Some(Duration::from_secs(10)),
1129 default_namespace: "default".to_owned(),
1130 auto_create: crate::config::AutoCreate::Open,
1131 max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
1132 drain_timeout: Duration::from_secs(30),
1133 metrics: MetricsConfig { enabled: true },
1134 owned_shards: Vec::new(),
1135 cors_allowed_origins: Vec::new(),
1136 }
1137 }
1138
1139 /// An `OutboxConfig` with `enabled = true` and every required knob present, so
1140 /// the only remaining gate is the store-backend / outbox-table availability.
1141 fn enabled_outbox_config() -> OutboxConfig {
1142 OutboxConfig {
1143 enabled: true,
1144 poll_interval_ms: Some(250),
1145 batch_size: Some(64),
1146 max_attempts: Some(5),
1147 backoff_base_ms: Some(100),
1148 backoff_multiplier: Some(2),
1149 backoff_max_ms: Some(30_000),
1150 reconcile_interval_ms: None,
1151 reconcile_stale_after_ms: None,
1152 transport: OutboxTransport::Grpc,
1153 liminal_listen_address: None,
1154 }
1155 }
1156
1157 /// LSUB-4-2 / LSUB-4-6 (Memory-backend guard): commissioning the outbox
1158 /// dispatcher against the in-memory backend (which has no outbox table, so
1159 /// `outbox_store()` is `None`) is a configuration error, and the message names
1160 /// haematite as the required durable backend.
1161 #[tokio::test]
1162 async fn outbox_enabled_on_memory_backend_is_a_config_error() {
1163 let state = ServerState::build_with_store(InMemoryStore::default(), runtime_config())
1164 .await
1165 .expect("build in-memory state");
1166 let (_tx, rx) = tokio::sync::watch::channel(false);
1167 let error = maybe_spawn_outbox_dispatcher(
1168 &state,
1169 &enabled_outbox_config(),
1170 false,
1171 test_backpressure_settings(),
1172 &rx,
1173 "set outbox.liminal_listen_address in the test config",
1174 )
1175 .expect_err("outbox.enabled on the memory backend must be a config error");
1176 assert!(
1177 error.is_config(),
1178 "memory-backend outbox error must be Config"
1179 );
1180 let message = error.to_string();
1181 assert!(
1182 message.contains("store.backend=haematite"),
1183 "message must name the durable backend, got: {message}"
1184 );
1185 }
1186
1187 /// LSUB-4-1 (Fork-B fast path): with the outbox disabled (the default), the
1188 /// gate is a no-op even on a memory backend — nothing is spawned and no error
1189 /// is produced, so a default single-node boot is unchanged.
1190 #[tokio::test]
1191 async fn disabled_outbox_is_a_noop_on_any_backend() {
1192 let state = ServerState::build_with_store(InMemoryStore::default(), runtime_config())
1193 .await
1194 .expect("build in-memory state");
1195 let (_tx, rx) = tokio::sync::watch::channel(false);
1196 maybe_spawn_outbox_dispatcher(
1197 &state,
1198 &OutboxConfig::default(),
1199 false,
1200 test_backpressure_settings(),
1201 &rx,
1202 "set outbox.liminal_listen_address in the test config",
1203 )
1204 .expect("disabled outbox gate must be an infallible no-op");
1205 }
1206
1207 /// LSUB-4-4: the reconciler config resolves to `None` unless BOTH knobs are
1208 /// set — the condition under which the clustered-boot WARN fires.
1209 #[test]
1210 fn reconciler_config_absent_unless_both_knobs_set() {
1211 let mut config = enabled_outbox_config();
1212 // Neither knob: absent.
1213 assert!(
1214 resolve_outbox_reconciler_config(&config)
1215 .expect("resolve")
1216 .is_none()
1217 );
1218 // Only interval: still absent (the silent-backstop-absent default).
1219 config.reconcile_interval_ms = Some(1_000);
1220 assert!(
1221 resolve_outbox_reconciler_config(&config)
1222 .expect("resolve")
1223 .is_none()
1224 );
1225 // Both set: present.
1226 config.reconcile_stale_after_ms = Some(60_000);
1227 assert!(
1228 resolve_outbox_reconciler_config(&config)
1229 .expect("resolve")
1230 .is_some()
1231 );
1232 }
1233
1234 /// LSUB-PROD (13-6): the liminal transport requires `liminal_listen_address`.
1235 /// Commissioning the dispatcher with `transport = liminal` but no listen
1236 /// address is a configuration error naming the missing knob, rather than a
1237 /// panic or a silent fall-through to gRPC. Built over haematite (so
1238 /// the outbox-store gate passes and the missing-address check is actually
1239 /// reached). (Feature-gated: the liminal arm of `build_liminal_row_dispatch`
1240 /// only exists with `liminal-transport` on; in a feature-off build the same
1241 /// selection is the missing-feature error instead, covered by the type system
1242 /// rather than this test.)
1243 #[cfg(feature = "liminal-transport")]
1244 #[tokio::test]
1245 async fn liminal_transport_requires_listen_address() {
1246 use crate::config::{
1247 RuntimeSection, ServerConfig, StoreBackend, StoreConfig, WebSocketConfig,
1248 };
1249
1250 let data_dir = std::env::temp_dir().join(format!(
1251 "aion-lsub-prod-listen-guard-{}-{}",
1252 std::process::id(),
1253 std::time::SystemTime::now()
1254 .duration_since(std::time::UNIX_EPOCH)
1255 .map(|elapsed| elapsed.as_nanos())
1256 .unwrap_or_default()
1257 ));
1258 let mut outbox = enabled_outbox_config();
1259 outbox.transport = OutboxTransport::Liminal;
1260 outbox.liminal_listen_address = None;
1261 let config = ServerConfig {
1262 store: StoreConfig {
1263 backend: StoreBackend::Haematite,
1264 data_dir: Some(data_dir.to_string_lossy().into_owned()),
1265 // Required, no default: the haematite boot path refuses a config
1266 // that does not rule on the node cache's byte ceiling.
1267 node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
1268 lock_acquisition_patience_ms: Some(250),
1269 lock_acquisition_retry_cadence_ms: Some(5),
1270 ..StoreConfig::default()
1271 },
1272 runtime: RuntimeSection {
1273 scheduler_threads: 1,
1274 jit_threshold: None,
1275 query_timeout_ms: Some(10_000),
1276 },
1277 websocket: WebSocketConfig {
1278 outbound_buffer_bound: 32,
1279 event_broadcast_capacity: Some(64),
1280 cluster_broadcast_capacity: Some(64),
1281 },
1282 outbox: outbox.clone(),
1283 // Required, no default: the transcript drain's flush policy.
1284 observability: crate::config::ObservabilityConfig::with_flush_policy(64, 0),
1285 ..ServerConfig::default()
1286 };
1287 let state = ServerState::build(config)
1288 .await
1289 .expect("build haematite state");
1290 let (_tx, rx) = tokio::sync::watch::channel(false);
1291
1292 let error = maybe_spawn_outbox_dispatcher(
1293 &state,
1294 &outbox,
1295 false,
1296 test_backpressure_settings(),
1297 &rx,
1298 "add `liminal_listen_address = \"127.0.0.1:50061\"` to `[outbox]` in the test config",
1299 )
1300 .expect_err("liminal transport without a listen address must be a config error");
1301 assert!(
1302 error.is_config(),
1303 "missing-listen-address error must be Config"
1304 );
1305 assert!(
1306 error.to_string().contains("liminal_listen_address"),
1307 "error must name the missing knob, got: {error}"
1308 );
1309 // #180 review MAJ-4: the refusal must carry the caller's threaded
1310 // where-to-edit hint, so the production message names the resolved
1311 // config FILE, not just the key.
1312 assert!(
1313 error.to_string().contains("in the test config"),
1314 "error must carry the threaded config-location hint, got: {error}"
1315 );
1316 }
1317}
1318
1319/// LSUB-PROD (13-6): production-boot cross-node round-trip over the REAL wiring.
1320///
1321/// This is the proof that the production boot now does the full round-trip the
1322/// retired stub could not. It drives the EXACT production commissioning function
1323/// `run_server` calls — [`maybe_spawn_outbox_dispatcher`] — over a real
1324/// [`ServerState`] built with `outbox.enabled`, `transport = liminal`, and a
1325/// `liminal_listen_address`. That function lifts the full push wiring
1326/// (`build_liminal_row_dispatch`): it hosts the liminal worker listener, builds
1327/// [`RegistryLiminalDispatch`](crate::worker::RegistryLiminalDispatch) over the
1328/// SAME registry the gRPC path uses and the SAME
1329/// [`ServerOutboxDeliveryCallback`](crate::worker::ServerOutboxDeliveryCallback)
1330/// (over the live engine), and spawns the real [`OutboxDispatcher`].
1331///
1332/// A REAL remote [`LiminalActivityWorker`](aion_worker::LiminalActivityWorker)
1333/// connects IN to the listener and self-registers in-band. A `collect_four`
1334/// fan-out is started over the REAL HTTP transport, which stages four pending
1335/// outbox rows; the production-wired dispatcher claims and pushes each to the
1336/// worker, the worker executes it, and its completion re-enters aion through the
1337/// production engine callback — `record_fan_out_completion` — driving the
1338/// workflow to a recorded terminal. The proof asserts BOTH: the worker observably
1339/// executed the activities, AND the terminals were recorded in history (four
1340/// `ActivityCompleted` + one `WorkflowCompleted`), which the stub's
1341/// publish-and-mark-done path never achieved.
1342#[cfg(all(test, feature = "liminal-transport"))]
1343mod lsub_prod_xnode_e2e {
1344 #![allow(clippy::expect_used)]
1345
1346 use std::net::SocketAddr;
1347 use std::path::PathBuf;
1348 use std::sync::Arc;
1349 use std::sync::atomic::{AtomicUsize, Ordering};
1350 use std::time::{Duration, Instant};
1351
1352 use aion_core::Event;
1353 use aion_package::{
1354 ActionContract, BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest,
1355 ManifestVersion, PackageBuilder, PackageContract, WorkerContract,
1356 };
1357 use aion_worker::{ActivityRegistry, LiminalActivityWorker, WorkerConfig};
1358 use axum::body;
1359 use axum::http::{Request, StatusCode};
1360 use serde_json::json;
1361 use tower::ServiceExt;
1362
1363 use super::{BackpressureSettings, maybe_spawn_outbox_dispatcher};
1364 use crate::ServerState;
1365 use crate::api::http::http_router;
1366 use crate::config::{
1367 OutboxConfig, OutboxTransport, RuntimeSection, ServerConfig, StoreBackend, StoreConfig,
1368 WebSocketConfig,
1369 };
1370
1371 type TestError = Box<dyn std::error::Error + Send + Sync>;
1372
1373 /// The `collect_four` fixture passes each member the JSON string `"in"` as
1374 /// activity input, so the worker handler decodes a [`String`], not a struct.
1375 type FanInput = String;
1376
1377 const NAMESPACE: &str = "default";
1378 const TASK_QUEUE: &str = "default";
1379 const OUTBOX_MODULE: &str = "aion_outbox_fixture";
1380 const OUTBOX_BEAM: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.beam");
1381 const OUTBOX_SOURCE: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.erl");
1382 const FAN_OUT: usize = 4;
1383 const FAN_ACTIVITY_TYPES: [&str; FAN_OUT] = ["fan:0", "fan:1", "fan:2", "fan:3"];
1384 const POLL_DEADLINE: Duration = Duration::from_secs(20);
1385 /// The one fan-out member the reconnect pin holds. Any of the four would do —
1386 /// they are dispatched independently and served by identical handlers.
1387 const HELD_ACTIVITY_TYPE: &str = FAN_ACTIVITY_TYPES[0];
1388
1389 fn test_error(message: impl std::fmt::Display) -> TestError {
1390 message.to_string().into()
1391 }
1392
1393 /// Reserve a loopback port and return it: the liminal listener binds this exact
1394 /// address (the production path binds the configured `liminal_listen_address`,
1395 /// so the test must commit to a concrete port the worker can also dial).
1396 fn reserve_loopback_port() -> Result<SocketAddr, TestError> {
1397 let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(test_error)?;
1398 let address = listener.local_addr().map_err(test_error)?;
1399 drop(listener);
1400 Ok(address)
1401 }
1402
1403 /// The fixture's queue-scoped `.v4` contract: the four `fan:N` activities
1404 /// `collect_four` schedules, declared on the queue its worker actually polls.
1405 ///
1406 /// Why the archive cannot just carry the manifest-derived record: by design
1407 /// `PackageContract::from_manifest` "never invents a queue", so a manifest's
1408 /// bare activity names land in `unscoped_activities` — and this server boots
1409 /// queue-routed, where an unscoped catalog is a terminal
1410 /// `NO_QUEUE_DECLARATION` at start admission
1411 /// (`aion::lifecycle::start_admission`). That refusal is EARNED: an unserved
1412 /// queue would otherwise wait silently forever. So the derived record is
1413 /// amended rather than bypassed — the same four names move out of
1414 /// `unscoped_activities` and onto the queue that serves them — and the
1415 /// package still loads through the production boot path with the `.v4`
1416 /// identity `PackageBuilder` stamps over this exact contract.
1417 ///
1418 /// The action schemas come from the SAME generator the worker's typed
1419 /// registry uses, for the SAME Rust types: `collect_four` passes each member
1420 /// the JSON string `"in"` and the handler returns a [`String`]. Deriving both
1421 /// sides from `activity_descriptor::<FanInput, String>` means the package's
1422 /// declaration and the worker's advertisement cannot drift apart, so
1423 /// registration admission (`WORKER_CONTRACT_MISMATCH`) compares two schemas
1424 /// with one source.
1425 fn fixture_contract(manifest: &Manifest) -> Result<PackageContract, TestError> {
1426 let mut actions = Vec::with_capacity(FAN_ACTIVITY_TYPES.len());
1427 for activity_type in FAN_ACTIVITY_TYPES {
1428 let descriptor = aion_worker::activity_descriptor::<FanInput, String>(activity_type)
1429 .map_err(test_error)?;
1430 actions.push(ActionContract {
1431 name: descriptor.name,
1432 input_schema: descriptor.input_schema,
1433 output_schema: descriptor.output_schema,
1434 node: None,
1435 timeout: None,
1436 retry: None,
1437 advisory: false,
1438 // A typed `String -> String` handler serves these, not an agent
1439 // harness — the fan fixture's shape merely coincides with an
1440 // agent seam's, and marking it would route it somewhere no
1441 // handler is.
1442 agent: false,
1443 // A connected worker serves this fixture's queue, so the
1444 // declaration carries no body of its own.
1445 body: None,
1446 });
1447 }
1448 let mut contract = PackageContract::from_manifest(manifest);
1449 contract.workers = vec![WorkerContract {
1450 task_queue: TASK_QUEUE.to_owned(),
1451 actions,
1452 }];
1453 contract.unscoped_activities.clear();
1454 Ok(contract)
1455 }
1456
1457 /// Build the `collect_four` package on disk so the production state-build path
1458 /// loads it exactly as it loads operator-supplied `workflow_packages`.
1459 fn write_package_archive(dir: &std::path::Path) -> Result<PathBuf, TestError> {
1460 let beams =
1461 BeamSet::new(vec![BeamModule::new(OUTBOX_MODULE, OUTBOX_BEAM)]).map_err(test_error)?;
1462 let manifest = Manifest {
1463 entry_module: OUTBOX_MODULE.to_owned(),
1464 entry_function: "collect_four".to_owned(),
1465 input_schema: json!({ "type": "object" }),
1466 output_schema: json!({}),
1467 timeout: Some(Duration::from_secs(30)),
1468 // The four ordinals `collect_four` actually fans out. This manifest
1469 // used to name one invented activity, `fixture_activity`, that the
1470 // fixture never schedules and no worker ever served.
1471 activities: FAN_ACTIVITY_TYPES
1472 .iter()
1473 .map(|activity_type| DeclaredActivity {
1474 activity_type: (*activity_type).to_owned(),
1475 })
1476 .collect(),
1477 version: ManifestVersion::new("stamped-by-builder"),
1478 format_version: CURRENT_FORMAT_VERSION,
1479 additional_workflows: Vec::new(),
1480 };
1481 let contract = fixture_contract(&manifest)?;
1482 let archive =
1483 PackageBuilder::with_source(manifest, beams, [(OUTBOX_MODULE, OUTBOX_SOURCE.to_vec())])
1484 .with_contract(contract)
1485 .write_to_bytes()
1486 .map_err(test_error)?;
1487 let path = dir.join("collect_four.aion");
1488 std::fs::write(&path, archive).map_err(test_error)?;
1489 Ok(path)
1490 }
1491
1492 /// A production-shaped `ServerConfig`: the haematite backend (so the boot store
1493 /// path shares the leaf as the dispatcher's outbox store, exactly as
1494 /// `ServerState::build` does in production), `outbox.enabled`,
1495 /// `transport = liminal`, the reserved `liminal_listen_address`, and the
1496 /// `collect_four` package. Built through `ServerState::build` (not
1497 /// `build_with_store`), so this is the real boot store seam, not a test stand-in.
1498 fn server_config(
1499 data_dir: &std::path::Path,
1500 package_path: PathBuf,
1501 listen_address: SocketAddr,
1502 ) -> ServerConfig {
1503 ServerConfig {
1504 store: StoreConfig {
1505 backend: StoreBackend::Haematite,
1506 data_dir: Some(data_dir.to_string_lossy().into_owned()),
1507 // Required, no default: the haematite boot path refuses a config
1508 // that does not rule on the node cache's byte ceiling.
1509 node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
1510 lock_acquisition_patience_ms: Some(250),
1511 lock_acquisition_retry_cadence_ms: Some(5),
1512 ..StoreConfig::default()
1513 },
1514 runtime: RuntimeSection {
1515 scheduler_threads: 1,
1516 jit_threshold: None,
1517 query_timeout_ms: Some(10_000),
1518 },
1519 websocket: WebSocketConfig {
1520 outbound_buffer_bound: 32,
1521 event_broadcast_capacity: Some(64),
1522 cluster_broadcast_capacity: Some(64),
1523 },
1524 workflow_packages: vec![package_path],
1525 outbox: OutboxConfig {
1526 enabled: true,
1527 poll_interval_ms: Some(20),
1528 batch_size: Some(16),
1529 max_attempts: Some(5),
1530 backoff_base_ms: Some(50),
1531 backoff_multiplier: Some(2),
1532 backoff_max_ms: Some(1_000),
1533 reconcile_interval_ms: None,
1534 reconcile_stale_after_ms: None,
1535 transport: OutboxTransport::Liminal,
1536 liminal_listen_address: Some(listen_address.to_string()),
1537 },
1538 // Required, no default: the transcript drain's flush policy.
1539 observability: crate::config::ObservabilityConfig::with_flush_policy(64, 0),
1540 ..ServerConfig::default()
1541 }
1542 }
1543
1544 /// The remote worker self-describes for the fixture's pool `(default, default)`
1545 /// and registers a handler for every `fan:N` activity type, counting executions
1546 /// so the test proves it genuinely ran the pushed dispatches.
1547 fn worker_config() -> Result<WorkerConfig, TestError> {
1548 WorkerConfig::builder()
1549 .endpoint("unused-direct-address")
1550 .namespace(NAMESPACE)
1551 .task_queue(TASK_QUEUE)
1552 .identity("lsub-prod-worker")
1553 .max_concurrency(4)
1554 .reconnect_initial_backoff(Duration::from_millis(5))
1555 .reconnect_max_backoff(Duration::from_millis(20))
1556 .reconnect_max_attempts(3)
1557 .build()
1558 .map_err(test_error)
1559 }
1560
1561 fn worker_registry(executions: &Arc<AtomicUsize>) -> Result<Arc<ActivityRegistry>, TestError> {
1562 let mut registry = ActivityRegistry::new();
1563 for activity_type in FAN_ACTIVITY_TYPES {
1564 let executions = Arc::clone(executions);
1565 // `register_activity_with_contract`, not `register_activity`: the
1566 // bare form registers a handler with NO descriptor, so the worker
1567 // advertises four names and zero typed contracts, and admission —
1568 // which compares CONTRACTS — refuses the registration outright
1569 // (`WORKER_CONTRACT_MISMATCH`). Deriving the advertisement from
1570 // `<FanInput, String>` is what makes it the same source the
1571 // package's `fixture_contract` declares from, so the two sides
1572 // cannot drift.
1573 registry = registry
1574 .register_activity_with_contract(
1575 activity_type,
1576 move |_input: FanInput, _context| {
1577 let executions = Arc::clone(&executions);
1578 Box::pin(async move {
1579 executions.fetch_add(1, Ordering::SeqCst);
1580 Ok(activity_type.to_owned())
1581 })
1582 },
1583 )
1584 .map_err(test_error)?;
1585 }
1586 Ok(Arc::new(registry))
1587 }
1588
1589 /// Spawns the remote worker on its own OS thread with a current-thread runtime
1590 /// (the push receive is blocking), connecting IN to the production listener.
1591 struct WorkerThread {
1592 stop: Arc<std::sync::atomic::AtomicBool>,
1593 handle: Option<std::thread::JoinHandle<()>>,
1594 }
1595
1596 impl WorkerThread {
1597 fn spawn(address: String, config: WorkerConfig, registry: Arc<ActivityRegistry>) -> Self {
1598 let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1599 let thread_stop = Arc::clone(&stop);
1600 let handle = std::thread::spawn(move || {
1601 let runtime = match tokio::runtime::Builder::new_current_thread()
1602 .enable_all()
1603 .build()
1604 {
1605 Ok(runtime) => runtime,
1606 Err(error) => {
1607 eprintln!("worker runtime build failed: {error}");
1608 return;
1609 }
1610 };
1611 runtime.block_on(async move {
1612 let worker = match LiminalActivityWorker::connect(&address, &config, registry) {
1613 Ok(worker) => worker,
1614 Err(error) => {
1615 eprintln!("worker connect failed: {error}");
1616 return;
1617 }
1618 };
1619 if let Err(error) = worker
1620 .serve_until(|| thread_stop.load(Ordering::SeqCst))
1621 .await
1622 {
1623 eprintln!("worker serve loop ended with error: {error}");
1624 }
1625 });
1626 });
1627 Self {
1628 stop,
1629 handle: Some(handle),
1630 }
1631 }
1632
1633 /// Spawn the worker through [`aion_worker::serve_with_redial`] — the entry
1634 /// point every REAL worker uses — so a broken link is survivable.
1635 ///
1636 /// [`Self::spawn`] uses `LiminalActivityWorker::serve_until`, which returns
1637 /// the first transport error by design: a single-connection serve has no
1638 /// survivor to migrate to. That is the right shape for a test whose link
1639 /// never breaks, and the wrong instrument entirely for one whose link is
1640 /// broken on purpose — a worker that dies at the break can only ever show
1641 /// that outstanding work fails, whoever is at fault.
1642 ///
1643 /// The redial driver is SYNCHRONOUS and builds its own current-thread
1644 /// runtime, so it runs on the bare thread rather than inside one.
1645 fn spawn_redialing(
1646 address: String,
1647 config: WorkerConfig,
1648 registry: Arc<ActivityRegistry>,
1649 timing: aion_worker::RedialTiming,
1650 ) -> Self {
1651 let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1652 let thread_stop = Arc::clone(&stop);
1653 let handle = std::thread::spawn(move || {
1654 if let Err(error) = aion_worker::serve_with_redial(
1655 vec![address],
1656 &config,
1657 ®istry,
1658 timing,
1659 &thread_stop,
1660 None,
1661 || {},
1662 ) {
1663 eprintln!("redialing worker ended with error: {error}");
1664 }
1665 });
1666 Self {
1667 stop,
1668 handle: Some(handle),
1669 }
1670 }
1671
1672 fn stop(mut self) {
1673 self.stop.store(true, Ordering::SeqCst);
1674 if let Some(handle) = self.handle.take() {
1675 handle.join().ok();
1676 }
1677 }
1678 }
1679
1680 fn count_completed(history: &[Event]) -> usize {
1681 history
1682 .iter()
1683 .filter(|event| matches!(event, Event::ActivityCompleted { .. }))
1684 .count()
1685 }
1686
1687 fn count_workflow_completed(history: &[Event]) -> usize {
1688 history
1689 .iter()
1690 .filter(|event| matches!(event, Event::WorkflowCompleted { .. }))
1691 .count()
1692 }
1693
1694 async fn wait_for_history<F>(
1695 store: &dyn aion_store::ReadableEventStore,
1696 workflow_id: &aion_core::WorkflowId,
1697 description: &str,
1698 predicate: F,
1699 ) -> Result<Vec<Event>, TestError>
1700 where
1701 F: Fn(&[Event]) -> bool,
1702 {
1703 let deadline = Instant::now() + POLL_DEADLINE;
1704 loop {
1705 let history = store.read_history(workflow_id).await.map_err(test_error)?;
1706 if predicate(&history) {
1707 return Ok(history);
1708 }
1709 if Instant::now() > deadline {
1710 return Err(test_error(format!(
1711 "timed out waiting for {description}: {history:#?}"
1712 )));
1713 }
1714 tokio::time::sleep(Duration::from_millis(25)).await;
1715 }
1716 }
1717
1718 /// Start the loaded `collect_four` workflow over the REAL HTTP transport.
1719 async fn start_over_http(router: &axum::Router) -> Result<aion_core::WorkflowId, TestError> {
1720 let build_request = || -> Result<Request<body::Body>, TestError> {
1721 Request::builder()
1722 .uri("/workflows/start")
1723 .method("POST")
1724 .header("content-type", "application/json")
1725 .header("x-aion-subject", "ci")
1726 .header("x-aion-namespaces", NAMESPACE)
1727 .body(body::Body::from(
1728 serde_json::to_vec(&json!({
1729 "namespace": NAMESPACE,
1730 "workflow_type": OUTBOX_MODULE,
1731 "input": { "fixture": "input" },
1732 }))
1733 .map_err(test_error)?,
1734 ))
1735 .map_err(test_error)
1736 };
1737 let response = router
1738 .clone()
1739 .oneshot(build_request()?)
1740 .await
1741 .map_err(test_error)?;
1742 let status = response.status();
1743 let bytes = body::to_bytes(response.into_body(), usize::MAX)
1744 .await
1745 .map_err(test_error)?
1746 .to_vec();
1747 if status != StatusCode::OK {
1748 return Err(test_error(format!(
1749 "workflow start over HTTP must succeed, got {status}: {}",
1750 String::from_utf8_lossy(&bytes)
1751 )));
1752 }
1753 let body: serde_json::Value = serde_json::from_slice(&bytes).map_err(test_error)?;
1754 // The HTTP wire contract (`clean_dtos::StartWorkflowResponse`) serializes
1755 // `workflow_id` as a plain UUID string, not a nested `{ uuid }` object.
1756 let workflow_id = body["workflow_id"]
1757 .as_str()
1758 .ok_or_else(|| test_error("start response missing workflow id"))?
1759 .parse::<uuid::Uuid>()
1760 .map_err(test_error)?;
1761 Ok(aion_core::WorkflowId::new(workflow_id))
1762 }
1763
1764 /// How long a freshly connected worker needs before the dispatch path may
1765 /// select it, DERIVED from the same two facts the server derives it from.
1766 ///
1767 /// A worker is dispatch-ineligible until it serves an OPENING PROBATION:
1768 /// [`Reachability::is_proved`] requires `DISPATCH_PROBATION_PINGS` consecutive
1769 /// answered liveness pings, at the probe's cadence of
1770 /// [`sweep_interval`](crate::worker::sweep_interval)`(heartbeat_window)`. The
1771 /// constant's own documentation states the cost — *"at the probe's cadence a
1772 /// fresh worker is undispatchable for K cadences while its first dispatches
1773 /// park"* — so this is designed behaviour a test must wait out, not a delay to
1774 /// be shortened.
1775 ///
1776 /// One extra cadence is allowed because the first round lands at an arbitrary
1777 /// offset inside the first interval: the worker connects between rounds, so it
1778 /// can miss up to one whole cadence before its first answer is even counted.
1779 ///
1780 /// # Why this is not a raised timeout
1781 ///
1782 /// It was 5 seconds, fixed, and that is how this test became one of four
1783 /// documented carriers of a load-sensitive flake
1784 /// (`gate-logs/lock-race-attribution/VERDICT.md`). The mechanism, measured:
1785 /// `dispatch_ineligible` starts EMPTY and `select_worker` filters only against
1786 /// what the probe has published, so a run in which **no probe round lands
1787 /// inside the window** selects the worker immediately and passes, while a run
1788 /// in which one does correctly withholds it for ~2 cadences and fails. On the
1789 /// default 30s window that is 7.5s per cadence against a 5s wait.
1790 ///
1791 /// 🔴 The passing runs were the WRONG ones. They dispatched to a worker that
1792 /// had not served its probation — a path production does not permit, because
1793 /// production parks those dispatches. Waiting for genuine eligibility makes
1794 /// this test MORE production-shaped, not more lenient, and that is the reason
1795 /// to do it. Raising a bound until a flake stops is how a liveness bug gets
1796 /// buried; deriving the bound from the mechanism that sets it is not the same
1797 /// act, and the register warns about the first for good reason.
1798 fn eligibility_patience(config: &ServerConfig) -> Duration {
1799 let cadence = crate::worker::sweep_interval(config.worker.heartbeat_window);
1800 cadence * (crate::worker::heartbeat::DISPATCH_PROBATION_PINGS + 1)
1801 }
1802
1803 /// Wait until the worker's in-band registration lands in the SAME registry the
1804 /// dispatch path selects from, with every fan-out activity type eligible.
1805 ///
1806 /// On the deadline this reports the state that DISCRIMINATES the worlds a
1807 /// missed registration can be in, because the bare sentence it replaced —
1808 /// "worker never registered in-band for the pool" — is equally true in at
1809 /// least three of them, and they want different fixes:
1810 ///
1811 /// 1. the liminal listener never bound, so nothing could dial in;
1812 /// 2. the worker never connected, or died dialling;
1813 /// 3. it connected and registration was merely slow;
1814 /// 4. it connected, registered correctly, and the SELECTOR refused it anyway —
1815 /// because the liveness probe published it as unreachable, or because it is
1816 /// not indexed for the activity type it advertises.
1817 ///
1818 /// The fourth was not in the first version of this report, and it is the world
1819 /// a real occurrence turned out to be in: the listener was bound, a worker was
1820 /// registered under the right namespace and queue advertising all four activity
1821 /// types, and every `select_worker` still returned nothing. A report that
1822 /// cannot separate "not registered" from "registered and refused" names the
1823 /// wrong half of the system.
1824 ///
1825 /// That is not a hypothetical distinction here. This module's e2e is one of
1826 /// four documented carriers of a load-sensitive flake
1827 /// (`gate-logs/lock-race-attribution/VERDICT.md`), it fails through THIS wait,
1828 /// and the reason the carrier has never been explained is that the failure
1829 /// named the fact and withheld the cause.
1830 async fn wait_for_registration(
1831 registry: &crate::worker::ConnectedWorkerRegistry,
1832 heartbeat: &crate::worker::HeartbeatTracker,
1833 listen_address: SocketAddr,
1834 patience: Duration,
1835 ) -> Result<(), TestError> {
1836 let deadline = Instant::now() + patience;
1837 loop {
1838 let now = Instant::now();
1839 let mut ready = true;
1840 for activity_type in FAN_ACTIVITY_TYPES {
1841 let Some(worker) = registry
1842 .select_worker(NAMESPACE, TASK_QUEUE, activity_type, None)
1843 .map_err(test_error)?
1844 else {
1845 ready = false;
1846 break;
1847 };
1848 if !heartbeat
1849 .is_dispatch_reachable(worker.id(), now)
1850 .map_err(test_error)?
1851 {
1852 ready = false;
1853 break;
1854 }
1855 }
1856 if ready {
1857 return Ok(());
1858 }
1859 if Instant::now() > deadline {
1860 return Err(test_error(format!(
1861 "worker never registered in-band for the pool within {patience:?}{}",
1862 registration_diagnosis(registry, listen_address)
1863 )));
1864 }
1865 tokio::time::sleep(Duration::from_millis(10)).await;
1866 }
1867 }
1868
1869 /// The discriminator behind [`wait_for_registration`]'s failure: enough of the
1870 /// world to tell those three apart, gathered at the moment of the failure.
1871 fn registration_diagnosis(
1872 registry: &crate::worker::ConnectedWorkerRegistry,
1873 listen_address: SocketAddr,
1874 ) -> String {
1875 let mut lines = vec![String::from("--- registration diagnosis ---")];
1876 // World 1, PROBED rather than assumed. The port was reserved by binding a
1877 // listener and dropping it, so losing the race for it is a real
1878 // possibility rather than a theoretical one, and it is indistinguishable
1879 // from every other failure unless something asks.
1880 lines.push(
1881 match std::net::TcpStream::connect_timeout(&listen_address, Duration::from_millis(500))
1882 {
1883 Ok(stream) => {
1884 drop(stream);
1885 format!("listener {listen_address}: ACCEPTS — the port is bound and dialable")
1886 }
1887 Err(error) => format!(
1888 "listener {listen_address}: NOT connectable ({error}) — nothing could have \
1889 registered, so this is not a timing problem"
1890 ),
1891 },
1892 );
1893 // Worlds 2 and 3: did any worker arrive at all, and if one did, what does
1894 // the registry hold for it against what the dispatch path asks of it? A
1895 // worker present under a different pool or advertising different activity
1896 // types is a contract mismatch wearing a timeout's clothes.
1897 match registry.all_workers() {
1898 Err(error) => lines.push(format!("registry: UNREADABLE ({error})")),
1899 Ok(workers) if workers.is_empty() => lines.push(String::from(
1900 "registry: EMPTY — no worker of any pool registered, so no connection ever \
1901 completed an in-band registration",
1902 )),
1903 Ok(workers) => {
1904 lines.push(format!("registry: {} worker(s) registered", workers.len()));
1905 for worker in &workers {
1906 lines.push(format!(
1907 " id={:?} namespaces={:?} task_queue={:?} node={:?} types={:?}",
1908 worker.id(),
1909 worker.namespaces(),
1910 worker.task_queue(),
1911 worker.node(),
1912 worker.activity_types()
1913 ));
1914 }
1915 }
1916 }
1917 lines.push(format!(
1918 "asked of it: namespace={NAMESPACE:?} task_queue={TASK_QUEUE:?}"
1919 ));
1920 // The liveness probe's reachability verdict. `select_worker` skips every
1921 // worker in this set, so a registered, correctly-advertised worker that is
1922 // listed here is refused for a reason nothing else in this report shows.
1923 lines.push(match registry.dispatch_ineligible() {
1924 Ok(ineligible) if ineligible.is_empty() => {
1925 String::from("dispatch-ineligible: none — reachability is not refusing anyone")
1926 }
1927 Ok(ineligible) => format!(
1928 "dispatch-ineligible: {ineligible:?} — the liveness probe has published these \
1929 as unreachable and select_worker skips them"
1930 ),
1931 Err(error) => format!("dispatch-ineligible: UNREADABLE ({error})"),
1932 });
1933 // Which of the four the selector could not satisfy, and — the part that
1934 // discriminates — the pool census beside each refusal.
1935 //
1936 // `select_worker` filters on THREE things: the activity index for
1937 // `(namespace, task_queue) + activity_type`, the node pin, and the
1938 // dispatch-ineligible set. The census counts the first two and does NOT
1939 // apply the third, so the pair of answers separates the remaining worlds
1940 // that a registry dump alone leaves fused:
1941 //
1942 // - census serves it, selector refuses ⇒ REACHABILITY, not registration;
1943 // - census serves 0 for the activity ⇒ the worker is in the pool but not
1944 // indexed for this activity type;
1945 // - census serves 0 for the pool ⇒ it is not in this pool at all,
1946 // whatever `all_workers` shows.
1947 //
1948 // Written after the bare registry dump above failed to close a real case:
1949 // it proved the listener was bound and a worker with all four activity
1950 // types was registered, and still could not say why every selection
1951 // returned nothing.
1952 for activity_type in FAN_ACTIVITY_TYPES {
1953 let outcome = match registry.select_worker(NAMESPACE, TASK_QUEUE, activity_type, None) {
1954 Ok(Some(handle)) => format!("worker {:?}", handle.id()),
1955 Ok(None) => String::from("NO worker"),
1956 Err(error) => format!("error: {error}"),
1957 };
1958 let census = match registry.pool_census(NAMESPACE, TASK_QUEUE, activity_type, None) {
1959 Ok(census) => format!(
1960 "in_pool={} serving_activity={} compatible={} last_compatible_age={:?}",
1961 census.workers_in_pool,
1962 census.workers_serving_activity,
1963 census.compatible_workers,
1964 census.last_compatible_poller_age
1965 ),
1966 Err(error) => format!("census UNREADABLE ({error})"),
1967 };
1968 lines.push(format!(
1969 "select_worker({activity_type}) -> {outcome} [census: {census}]"
1970 ));
1971 }
1972 format!("\n {}", lines.join("\n "))
1973 }
1974
1975 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1976 async fn production_boot_dispatches_executes_and_records_over_liminal() -> Result<(), TestError>
1977 {
1978 let dir = crate::test_support::private_tempdir().map_err(test_error)?;
1979 let db_path = dir.path().join("aion.db");
1980 let package_path = write_package_archive(dir.path())?;
1981 // The production path binds the CONFIGURED listen address, so commit to a
1982 // concrete reserved loopback port the worker can also dial.
1983 let listen_address = reserve_loopback_port()?;
1984
1985 // (A) Build a real ServerState through the production boot path
1986 // (ServerState::build over a haematite ServerConfig): outbox enabled,
1987 // transport = liminal, the listen address set, collect_four loaded. This
1988 // shares the haematite leaf as the dispatcher's outbox store (the real boot
1989 // store seam) and installs the production ServerOutboxDeliveryCallback over
1990 // the live engine (gated on outbox.enabled).
1991 let config = server_config(&db_path, package_path, listen_address);
1992 let outbox_config = config.outbox.clone();
1993 // Captured before `build` consumes the config: the wait below is derived
1994 // from the very window this server is about to run its liveness probe on.
1995 let patience = eligibility_patience(&config);
1996 let state = ServerState::build(config).await.map_err(test_error)?;
1997
1998 // (B) Drive the EXACT production commissioning function run_server calls:
1999 // it hosts the liminal listener, builds RegistryLiminalDispatch over the
2000 // shared registry + engine callback, and spawns the real OutboxDispatcher.
2001 // Hold the returned listener guard for the test's lifetime, exactly as
2002 // run_server holds it.
2003 let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
2004 // Own-all, generous-default backpressure (single-node e2e): fraction 1 and
2005 // the platform default, so the ceiling never engages — the claim behaves
2006 // exactly as before, proving the production path is byte-identical on default.
2007 let backpressure_settings = BackpressureSettings {
2008 platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
2009 fraction: crate::worker::OwnedShardFraction::own_all(),
2010 };
2011 let listener_guard = maybe_spawn_outbox_dispatcher(
2012 &state,
2013 &outbox_config,
2014 false,
2015 backpressure_settings,
2016 &shutdown_rx,
2017 "set outbox.liminal_listen_address in the test config",
2018 )
2019 .map_err(test_error)?;
2020
2021 // (C) A REAL remote worker connects IN to the production listener and
2022 // self-registers in-band for the fixture's pool.
2023 let executions = Arc::new(AtomicUsize::new(0));
2024 let worker = WorkerThread::spawn(
2025 listen_address.to_string(),
2026 worker_config()?,
2027 worker_registry(&executions)?,
2028 );
2029
2030 // Wait until the in-band registration landed in the SAME registry the
2031 // dispatch path selects from (every fan-out activity type is eligible).
2032 let registry = state.worker_registry().clone();
2033 if let Err(error) = wait_for_registration(
2034 ®istry,
2035 state.heartbeat_tracker(),
2036 listen_address,
2037 patience,
2038 )
2039 .await
2040 {
2041 worker.stop();
2042 return Err(error);
2043 }
2044
2045 // (D) Start collect_four over the REAL HTTP transport: the engine stages
2046 // four pending outbox rows; the production-wired dispatcher claims and
2047 // pushes each to the worker.
2048 let router = http_router(state.clone()).map_err(test_error)?;
2049 let workflow_id = start_over_http(&router).await?;
2050
2051 // (E) THE PROOF: the worker executed all four activities AND every terminal
2052 // was recorded through the production engine callback (record_fan_out_completion)
2053 // — four ActivityCompleted + one WorkflowCompleted in durable history. This
2054 // is the full round-trip the retired stub never achieved.
2055 let reader = state.engine().map_err(test_error)?.store();
2056 let settled =
2057 wait_for_history(reader.as_ref(), &workflow_id, "fan-out settled", |events| {
2058 count_completed(events) == FAN_OUT && count_workflow_completed(events) == 1
2059 })
2060 .await?;
2061 assert_eq!(
2062 count_completed(&settled),
2063 FAN_OUT,
2064 "every fan-out member must record a terminal through the production callback"
2065 );
2066 assert_eq!(
2067 count_workflow_completed(&settled),
2068 1,
2069 "the workflow must complete exactly once"
2070 );
2071 assert_eq!(
2072 executions.load(Ordering::SeqCst),
2073 FAN_OUT,
2074 "the remote worker must have executed every pushed dispatch exactly once"
2075 );
2076
2077 // Teardown: stop the dispatcher + worker, drop the listener guard (its Drop
2078 // stops the accept worker), shut the engine down so durable appends finish.
2079 shutdown_tx.send(true).ok();
2080 worker.stop();
2081 drop(listener_guard);
2082 state.shutdown().map_err(test_error)?;
2083 Ok(())
2084 }
2085
2086 /// One dispatch as the WORKER saw it: the identity the server sent it under,
2087 /// and when it arrived.
2088 #[derive(Clone, Debug)]
2089 struct SeenDispatch {
2090 activity_type: String,
2091 activity_id: String,
2092 attempt: u32,
2093 at: Instant,
2094 }
2095
2096 /// A loopback TCP relay the test can BREAK, sitting between the worker and the
2097 /// production liminal listener.
2098 ///
2099 /// The worker dials this instead of the listener, so the test owns a socket it
2100 /// can shut from the outside. That is the only way to make a REAL
2101 /// [`LiminalActivityWorker`] lose its connection mid-flight without reaching
2102 /// inside either the worker or the server — and a link broken from the inside
2103 /// would be a different experiment, because the code under test would be the
2104 /// code doing the breaking.
2105 ///
2106 /// # Why this is not the relay in `tests/dead_man_switch_e2e.rs`
2107 ///
2108 /// That file has `WedgeableRelay`, which can both wedge and sever, and this is
2109 /// deliberately not it. The two cannot be one, for a structural reason rather
2110 /// than a matter of taste: an integration test links this crate as an ordinary
2111 /// dependency, so it can see neither `#[cfg(test)] pub(crate) mod test_support`
2112 /// nor the private `maybe_spawn_outbox_dispatcher` this harness is built on,
2113 /// and `src/` cannot see `tests/`. Sharing one instrument would mean exporting
2114 /// a public, feature-gated test surface from a production crate.
2115 ///
2116 /// So the split is stated rather than hidden, and this half is a strict subset:
2117 /// it only severs. Wedging — which leaves both sockets open and merely discards
2118 /// bytes, so writes keep succeeding into the kernel buffer — is a DIFFERENT
2119 /// instrument answering a different question. #69 is about a broken link, not
2120 /// a silent one.
2121 struct SeverableRelay {
2122 address: SocketAddr,
2123 /// Every relayed socket, held so [`Self::sever`] can break them.
2124 sockets: Arc<std::sync::Mutex<Vec<std::net::TcpStream>>>,
2125 stop: Arc<std::sync::atomic::AtomicBool>,
2126 handle: Option<std::thread::JoinHandle<()>>,
2127 }
2128
2129 impl SeverableRelay {
2130 /// Bind a loopback port and relay every accepted connection to `upstream`.
2131 fn spawn(upstream: SocketAddr) -> Result<Self, TestError> {
2132 let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(test_error)?;
2133 let address = listener.local_addr().map_err(test_error)?;
2134 // Non-blocking accept so the relay can be shut down deterministically
2135 // rather than by parking a thread in `accept` until something happens
2136 // to connect. Accepted sockets are put back into blocking mode
2137 // explicitly: on this platform they would otherwise inherit the flag
2138 // and every pump would spin on `WouldBlock`.
2139 listener.set_nonblocking(true).map_err(test_error)?;
2140 let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
2141 let sockets: Arc<std::sync::Mutex<Vec<std::net::TcpStream>>> =
2142 Arc::new(std::sync::Mutex::new(Vec::new()));
2143 let accept_stop = Arc::clone(&stop);
2144 let accept_sockets = Arc::clone(&sockets);
2145 let handle = std::thread::spawn(move || {
2146 while !accept_stop.load(Ordering::SeqCst) {
2147 match listener.accept() {
2148 Ok((downstream, _)) => {
2149 if let Err(error) =
2150 Self::relay_one(&downstream, upstream, &accept_sockets)
2151 {
2152 // The worker redials, so a connection this relay
2153 // fails to carry surfaces as a slower recovery
2154 // rather than as a wrong answer — but silence here
2155 // would make that indistinguishable from the
2156 // server never pushing, which is exactly the
2157 // confusion this pin exists to resolve.
2158 eprintln!("relay could not carry a connection: {error}");
2159 }
2160 }
2161 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
2162 std::thread::sleep(Duration::from_millis(2));
2163 }
2164 Err(error) => {
2165 eprintln!("relay accept failed: {error}");
2166 return;
2167 }
2168 }
2169 }
2170 });
2171 Ok(Self {
2172 address,
2173 sockets,
2174 stop,
2175 handle: Some(handle),
2176 })
2177 }
2178
2179 /// Dial upstream for one accepted connection and pump both directions.
2180 fn relay_one(
2181 downstream: &std::net::TcpStream,
2182 upstream: SocketAddr,
2183 sockets: &Arc<std::sync::Mutex<Vec<std::net::TcpStream>>>,
2184 ) -> Result<(), TestError> {
2185 downstream.set_nonblocking(false).map_err(test_error)?;
2186 let up = std::net::TcpStream::connect(upstream).map_err(test_error)?;
2187 let down_read = downstream.try_clone().map_err(test_error)?;
2188 let down_write = downstream.try_clone().map_err(test_error)?;
2189 let up_read = up.try_clone().map_err(test_error)?;
2190 let up_write = up.try_clone().map_err(test_error)?;
2191 let held = downstream.try_clone().map_err(test_error)?;
2192 let mut parked = sockets
2193 .lock()
2194 .map_err(|_| test_error("relay socket register poisoned"))?;
2195 parked.push(held);
2196 parked.push(up);
2197 drop(parked);
2198 for (from, to) in [(down_read, up_write), (up_read, down_write)] {
2199 std::thread::spawn(move || Self::pump(from, to));
2200 }
2201 Ok(())
2202 }
2203
2204 /// Copy one direction until the connection ends.
2205 ///
2206 /// A read or write error here IS the severed link in the expected case, and
2207 /// in every case it means the peer this pump exists to serve is gone: there
2208 /// is no party left to propagate to, so ending the pump is the handling,
2209 /// not an omission of it.
2210 fn pump(mut from: std::net::TcpStream, mut to: std::net::TcpStream) {
2211 use std::io::{Read, Write};
2212 let mut buffer = [0_u8; 8192];
2213 loop {
2214 match from.read(&mut buffer) {
2215 Ok(0) | Err(_) => return,
2216 Ok(read) => {
2217 if to.write_all(&buffer[..read]).is_err() {
2218 return;
2219 }
2220 }
2221 }
2222 }
2223 }
2224
2225 const fn address(&self) -> SocketAddr {
2226 self.address
2227 }
2228
2229 /// BREAK every relayed socket, and report how many were broken.
2230 ///
2231 /// The count is returned, and asserted non-zero by the caller, so that a
2232 /// sever which severed nothing can never masquerade as a measurement — the
2233 /// pin would otherwise pass by never having run its own experiment.
2234 fn sever(&self) -> Result<usize, TestError> {
2235 let mut parked = self
2236 .sockets
2237 .lock()
2238 .map_err(|_| test_error("relay socket register poisoned"))?;
2239 let mut severed = 0;
2240 for socket in parked.iter() {
2241 if socket.shutdown(std::net::Shutdown::Both).is_ok() {
2242 severed += 1;
2243 }
2244 }
2245 parked.clear();
2246 Ok(severed)
2247 }
2248
2249 fn shutdown(mut self) {
2250 self.stop.store(true, Ordering::SeqCst);
2251 if let Some(handle) = self.handle.take() {
2252 handle.join().ok();
2253 }
2254 }
2255 }
2256
2257 /// Registry for the reconnect pin: every dispatch is RECORDED with the identity
2258 /// the server sent it under, and [`HELD_ACTIVITY_TYPE`]'s FIRST dispatch holds
2259 /// — the work is finished, its reply is not yet on the wire — until released.
2260 ///
2261 /// Only the first is held. A blanket hold would stall the re-delivery this pin
2262 /// exists to observe, and the pin would then measure its own instrument.
2263 fn recording_registry(
2264 seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>,
2265 release: &Arc<std::sync::atomic::AtomicBool>,
2266 ) -> Result<Arc<ActivityRegistry>, TestError> {
2267 let mut registry = ActivityRegistry::new();
2268 for activity_type in FAN_ACTIVITY_TYPES {
2269 let seen = Arc::clone(seen);
2270 let release = Arc::clone(release);
2271 let arrivals = Arc::new(AtomicUsize::new(0));
2272 registry = registry
2273 .register_activity_with_contract(
2274 activity_type,
2275 move |_input: FanInput, context: &aion_worker::ActivityContext| {
2276 let seen = Arc::clone(&seen);
2277 let release = Arc::clone(&release);
2278 let arrivals = Arc::clone(&arrivals);
2279 let record = SeenDispatch {
2280 activity_type: activity_type.to_owned(),
2281 activity_id: context.activity_id().to_string(),
2282 attempt: context.attempt(),
2283 at: Instant::now(),
2284 };
2285 Box::pin(async move {
2286 // Recorded BEFORE the hold: a dispatch that arrives and
2287 // is never answered must still be visible, or the pin
2288 // cannot tell "never re-delivered" from "re-delivered
2289 // and lost again".
2290 match seen.lock() {
2291 Ok(mut log) => log.push(record),
2292 Err(_) => {
2293 return Err(aion_worker::ActivityFailure::terminal(
2294 "the pin's dispatch log is poisoned, so this run can \
2295 observe nothing — failing loudly rather than \
2296 returning a result no assertion could trust",
2297 ));
2298 }
2299 }
2300 let first = arrivals.fetch_add(1, Ordering::SeqCst) == 0;
2301 if activity_type == HELD_ACTIVITY_TYPE && first {
2302 while !release.load(Ordering::SeqCst) {
2303 tokio::time::sleep(Duration::from_millis(5)).await;
2304 }
2305 }
2306 Ok(activity_type.to_owned())
2307 })
2308 },
2309 )
2310 .map_err(test_error)?;
2311 }
2312 Ok(Arc::new(registry))
2313 }
2314
2315 fn dispatches_of(
2316 seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>,
2317 activity_type: &str,
2318 ) -> Result<Vec<SeenDispatch>, TestError> {
2319 let log = seen
2320 .lock()
2321 .map_err(|_| test_error("the pin's dispatch log is poisoned"))?;
2322 Ok(log
2323 .iter()
2324 .filter(|record| record.activity_type == activity_type)
2325 .cloned()
2326 .collect())
2327 }
2328
2329 fn dispatch_log(seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>) -> String {
2330 match seen.lock() {
2331 Ok(log) => format!("{:#?}", *log),
2332 Err(_) => String::from("<poisoned>"),
2333 }
2334 }
2335
2336 /// aion #69 at the ENGINE level: what the system DOES after an activity's
2337 /// completion is lost to a broken link.
2338 ///
2339 /// # What this measures, and why the transport-level pin cannot
2340 ///
2341 /// #69's existing red-first pin lives on its fix branch rather than here (it
2342 /// is red on purpose and lands with the fix), and it establishes that the
2343 /// completion is DISCARDED: the server abandons the correlated reply-wait the
2344 /// moment the delivering connection closes. It drives `WorkerDelivery`
2345 /// directly, with no engine, no store and no workflow behind it, so it can say
2346 /// nothing at all about what happens NEXT. That gap is the whole severity of
2347 /// #69: "the work is repeated once" and "the work is lost" are priced very
2348 /// differently, and nothing in-tree could tell them apart.
2349 ///
2350 /// So this pin observes four things, and asserts only what must hold in EVERY
2351 /// world — including the one a #69 fix creates:
2352 ///
2353 /// - **O4, ASSERTED** — the workflow still reaches a recorded terminal. This is
2354 /// the invariant: a broken link must not cost the workflow. It is not a weak
2355 /// assertion, because `collect_four` consumes all four members, so the
2356 /// workflow cannot complete while any member's work is missing;
2357 /// - **O1, REPORTED** — whether the held activity is dispatched a SECOND time.
2358 /// This is the MECHANISM, and the mechanism is what a fix changes: a fix that
2359 /// carries the completion across the reconnect would produce NO re-delivery,
2360 /// and a pin asserting one would read that fix as a regression.
2361 /// regression;
2362 /// - **O2, asserted CONDITIONALLY** — if a re-delivery happened it must carry
2363 /// the activity's OWN identity. That is what makes the finished work
2364 /// discarded rather than recovered; a re-delivery under a different identity
2365 /// is a different defect and must not pass quietly;
2366 /// - **O3, REPORTED** — the elapsed time from the break to the re-delivery, as
2367 /// a NUMBER asserted against nothing. No threshold is invented here: the
2368 /// right bound is a conversation to have with the measurement in hand.
2369 ///
2370 /// ⚠️ **O3 is recovery LATENCY, and latency is not COST.** The number is
2371 /// measured on a fixture activity that is a pure `String -> String`, so its
2372 /// repeat costs microseconds. The real cost of a repeat is the repeated
2373 /// activity's own runtime plus its repeated SIDE EFFECTS, which this pin does
2374 /// not measure and structurally cannot: #69's own exhibit was an *agent*
2375 /// activity, whose repeat is minutes of compute and files written twice.
2376 /// Quote the finding — *repeated work, not lost work, one repeat per in-flight
2377 /// activity* — rather than the milliseconds, which carry their premise (a
2378 /// trivial activity) only for as long as someone remembers to attach it.
2379 ///
2380 /// The settle-wait below is bounded by [`POLL_DEADLINE`], so this pin cannot
2381 /// hang; but that bound is ~100x the observed recovery, so it is a liveness
2382 /// guard and NOT a latency guard. A large latency regression would still pass
2383 /// here, reported in O3 and asserted by nothing — deliberately, because the
2384 /// correct bound is not derivable from the samples taken so far.
2385 ///
2386 /// Executions are REPORTED, never asserted equal to the fan-out. A transport
2387 /// that can lose a reply gives at-least-once delivery, so the sibling test's
2388 /// `executions == FAN_OUT` is the wrong shape here and must not be copied
2389 /// across.
2390 ///
2391 /// # The world this models
2392 ///
2393 /// One server process with its transport-loss ledger live in memory, a worker
2394 /// that redials the SAME address, and a SINGLE loss — well inside
2395 /// `TRANSPORT_LOSS_BUDGET_WINDOWS`. It is NOT a server restart and NOT budget
2396 /// exhaustion, both of which are different worlds with different recoveries.
2397 /// The re-delivery this venue can produce is the outbox dispatcher's re-claim
2398 /// under the `max_attempts`/backoff this test's config sets, not the #266
2399 /// recovery replay — which is what gives O3's number a slot to mean anything in.
2400 ///
2401 /// The relay's own accept poll (2ms) sits inside the measured elapsed.
2402 ///
2403 /// ⚠️ This pin shares a venue with
2404 /// `production_boot_dispatches_executes_and_records_over_liminal`, one of four
2405 /// documented carriers of a load-sensitive flake — 2/24 on a base that
2406 /// predates it (`gate-logs/lock-race-attribution/VERDICT.md`). It inherits that
2407 /// sensitivity, and a red here should be read against that register first.
2408 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2409 async fn a_completion_lost_to_a_severed_link_is_re_dispatched_and_the_workflow_settles()
2410 -> Result<(), TestError> {
2411 let dir = crate::test_support::private_tempdir().map_err(test_error)?;
2412 let db_path = dir.path().join("aion.db");
2413 let package_path = write_package_archive(dir.path())?;
2414 let listen_address = reserve_loopback_port()?;
2415
2416 let config = server_config(&db_path, package_path, listen_address);
2417 let outbox_config = config.outbox.clone();
2418 // Captured before `build` consumes the config: the wait below is derived
2419 // from the very window this server is about to run its liveness probe on.
2420 let patience = eligibility_patience(&config);
2421 let state = ServerState::build(config).await.map_err(test_error)?;
2422 let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
2423 let backpressure_settings = BackpressureSettings {
2424 platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
2425 fraction: crate::worker::OwnedShardFraction::own_all(),
2426 };
2427 let listener_guard = maybe_spawn_outbox_dispatcher(
2428 &state,
2429 &outbox_config,
2430 false,
2431 backpressure_settings,
2432 &shutdown_rx,
2433 "set outbox.liminal_listen_address in the test config",
2434 )
2435 .map_err(test_error)?;
2436
2437 // The worker dials the RELAY, which carries it to the production listener.
2438 let relay = SeverableRelay::spawn(listen_address)?;
2439 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
2440 let release = Arc::new(std::sync::atomic::AtomicBool::new(false));
2441 // The redial timings are the ones this module's `worker_config` already
2442 // declares, read off it rather than re-chosen here: a reconnect pin that
2443 // picked its own recovery timings would be measuring a world of its own.
2444 let config = worker_config()?;
2445 let timing = aion_worker::RedialTiming::new(
2446 config.reconnect.initial_backoff,
2447 config.reconnect.max_backoff,
2448 );
2449 let worker = WorkerThread::spawn_redialing(
2450 relay.address().to_string(),
2451 config,
2452 recording_registry(&seen, &release)?,
2453 timing,
2454 );
2455
2456 let outcome =
2457 observe_reconnect(&state, &relay, &seen, &release, listen_address, patience).await;
2458
2459 // Teardown runs on EVERY path, including a failing one: a leaked worker
2460 // thread or listener poisons whatever runs next, and this venue is already
2461 // load-sensitive enough without the pin adding to it.
2462 shutdown_tx.send(true).ok();
2463 release.store(true, Ordering::SeqCst);
2464 worker.stop();
2465 relay.shutdown();
2466 drop(listener_guard);
2467 state.shutdown().map_err(test_error)?;
2468 outcome
2469 }
2470
2471 /// The measurement behind
2472 /// [`a_completion_lost_to_a_severed_link_is_re_dispatched_and_the_workflow_settles`],
2473 /// split out so its many early returns cannot skip the harness teardown.
2474 /// Wait until the held member is dispatched and holding — the moment the link
2475 /// can be broken — and report how many of its siblings had already settled.
2476 ///
2477 /// The split at the break is REPORTED, never required. An earlier draft
2478 /// demanded that the other three settle first, for a single-variable
2479 /// experiment. Measured across runs it simply varies: the four pushes land
2480 /// within microseconds of each other and which records a terminal first is a
2481 /// race, so requiring a particular split would fail the pin for a reason that
2482 /// has nothing to do with what it measures.
2483 async fn await_held_dispatch(
2484 reader: &dyn aion_store::ReadableEventStore,
2485 workflow_id: &aion_core::WorkflowId,
2486 seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>,
2487 ) -> Result<(SeenDispatch, usize), TestError> {
2488 let deadline = Instant::now() + POLL_DEADLINE;
2489 loop {
2490 if let Some(first) = dispatches_of(seen, HELD_ACTIVITY_TYPE)?.first() {
2491 let at_the_break = reader.read_history(workflow_id).await.map_err(test_error)?;
2492 return Ok((first.clone(), count_completed(&at_the_break)));
2493 }
2494 if Instant::now() > deadline {
2495 let history = reader.read_history(workflow_id).await.map_err(test_error)?;
2496 return Err(test_error(format!(
2497 "{HELD_ACTIVITY_TYPE} was never dispatched at all within {POLL_DEADLINE:?}, \
2498 so there was no held completion to lose and this run measured nothing.\n\
2499 dispatch log: {}\nhistory: {history:#?}",
2500 dispatch_log(seen),
2501 )));
2502 }
2503 tokio::time::sleep(Duration::from_millis(25)).await;
2504 }
2505 }
2506
2507 async fn observe_reconnect(
2508 state: &ServerState,
2509 relay: &SeverableRelay,
2510 seen: &Arc<std::sync::Mutex<Vec<SeenDispatch>>>,
2511 release: &Arc<std::sync::atomic::AtomicBool>,
2512 listen_address: SocketAddr,
2513 patience: Duration,
2514 ) -> Result<(), TestError> {
2515 wait_for_registration(
2516 state.worker_registry(),
2517 state.heartbeat_tracker(),
2518 listen_address,
2519 patience,
2520 )
2521 .await?;
2522
2523 let router = http_router(state.clone()).map_err(test_error)?;
2524 let workflow_id = start_over_http(&router).await?;
2525 let reader = state.engine().map_err(test_error)?.store();
2526
2527 let (first, settled_before) =
2528 await_held_dispatch(reader.as_ref(), &workflow_id, seen).await?;
2529
2530 // BREAK the link while the finished work is still holding its reply.
2531 let severed = relay.sever()?;
2532 let severed_at = Instant::now();
2533 if severed == 0 {
2534 return Err(test_error(
2535 "the relay severed NOTHING, so no link was ever broken and this run measured \
2536 nothing — a pass here would have been an artefact of the instrument",
2537 ));
2538 }
2539 // Release the hold: the worker now writes its reply into a dead socket.
2540 release.store(true, Ordering::SeqCst);
2541
2542 // O4 FIRST, because it is the INVARIANT: a broken link must not cost the
2543 // workflow. Every other observable here describes the MECHANISM by which
2544 // that holds, and the mechanism is exactly what a #69 fix is expected to
2545 // change — so asserting today's mechanism would make the fix read as a
2546 // regression, and would be asserting the enumeration rather than the
2547 // invariant.
2548 //
2549 // O4 is load-bearing rather than weak because `collect_four` CONSUMES all
2550 // four members: the workflow cannot reach a completed terminal while any
2551 // member's work is missing, so "the workflow settled" is not a state that
2552 // silently lost work can also produce.
2553 let settled = wait_for_history(
2554 reader.as_ref(),
2555 &workflow_id,
2556 "the workflow to settle after the severed link",
2557 |events| count_completed(events) == FAN_OUT && count_workflow_completed(events) == 1,
2558 )
2559 .await
2560 .map_err(|error| {
2561 test_error(format!(
2562 "O4 FAILED — the workflow did not settle after the link broke ({severed} \
2563 socket(s) severed), so the lost completion cost the workflow rather than \
2564 costing a repeat of the work.\n{error}\ndispatch log: {}",
2565 dispatch_log(seen),
2566 ))
2567 })?;
2568 assert_eq!(
2569 count_completed(&settled),
2570 FAN_OUT,
2571 "every fan-out member must still record a terminal after the link broke"
2572 );
2573 assert_eq!(
2574 count_workflow_completed(&settled),
2575 1,
2576 "the workflow must complete exactly once even though a completion was lost"
2577 );
2578
2579 // O1/O2/O3 — the MECHANISM, reported. O2 is asserted only CONDITIONALLY:
2580 // if a re-delivery happened it must have carried the activity's own
2581 // identity, because a re-delivery under a different identity would be a
2582 // different defect entirely and must not pass quietly. If no re-delivery
2583 // happened, the completion survived the reconnect — which is what a fixed
2584 // #69 looks like, and this pin should report it, not fail on it.
2585 let held = dispatches_of(seen, HELD_ACTIVITY_TYPE)?;
2586 match held.get(1) {
2587 None => println!(
2588 "aion#69 — {HELD_ACTIVITY_TYPE} ({}) was NOT re-dispatched and the workflow \
2589 still settled, so the held completion survived the break; {settled_before} of \
2590 {FAN_OUT} members had settled when it broke, {severed} socket(s) severed",
2591 first.activity_id,
2592 ),
2593 Some(second) => {
2594 if second.activity_id != first.activity_id {
2595 return Err(test_error(format!(
2596 "O2 FAILED — the re-delivery carried a DIFFERENT activity identity. The \
2597 first dispatch was {} (attempt {}) and the second was {} (attempt {}), \
2598 so the work was not re-run under its own identity and #69's framing \
2599 does not describe what happened here.",
2600 first.activity_id, first.attempt, second.activity_id, second.attempt,
2601 )));
2602 }
2603 let recovery = second.at.saturating_duration_since(severed_at);
2604 println!(
2605 "aion#69 O3 — re-delivery of {} ({}) took {}ms from the link breaking; \
2606 first attempt {}, second attempt {}; {settled_before} of {FAN_OUT} members \
2607 had already recorded a terminal when the link broke; {severed} socket(s) \
2608 severed",
2609 HELD_ACTIVITY_TYPE,
2610 first.activity_id,
2611 recovery.as_millis(),
2612 first.attempt,
2613 second.attempt,
2614 );
2615 }
2616 }
2617
2618 let all = seen
2619 .lock()
2620 .map_err(|_| test_error("the pin's dispatch log is poisoned"))?
2621 .len();
2622 println!(
2623 "aion#69 — {all} dispatch(es) served for {FAN_OUT} activities; the transport is \
2624 at-least-once, so the excess is the repeated work a broken link costs"
2625 );
2626 Ok(())
2627 }
2628}