#![cfg(feature = "liminal-transport")]
#[path = "test_support/engine_guard.rs"]
mod engine_guard;
use aion_server::namespace::NamespaceGuard;
use std::collections::HashMap;
use std::error::Error;
use std::net::SocketAddr;
use std::path::Path;
use std::sync::Arc;
use std::sync::{
OnceLock,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use aion::activity::bridge::{ActivityDispatch, ActivityDispatcher};
use aion::durability::{FanOutItem, Recorder, WorkflowStartRecord};
use aion::signal::ConcreteSignalRouter;
use aion::{EngineBuilder, RuntimeHandle, SignalRouter};
use aion_core::{
DEFAULT_TASK_QUEUE, Event, PackageVersion, Payload, RunId, WorkflowId, WorkflowStatus,
};
use aion_package::{
ActionContract, BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity,
ExtractionLimits, Manifest, ManifestVersion, Package, PackageBuilder, PackageContract,
WorkerContract,
};
use aion_server::cluster::{ClusterSupervisor, SupervisorConfig, WatchedPeer};
use aion_server::worker::ActivityDispatcher as ServerActivityDispatcher;
use aion_server::worker::CompletionFences;
use aion_server::worker::HeartbeatTracker;
use aion_server::worker::liminal_task_delivery::LiminalTaskDelivery;
use aion_server::worker::task_delivery::WorkerTaskDelivery;
use aion_server::worker::{
ConnectedWorkerRegistry, DeliveryGate, LiminalCompletionSource, LiminalConnectionNotifier,
OutboxDeliveryCallback, OutboxDispatcher, OutboxDispatcherConfig, OutboxRowDispatch,
ServerOutboxDeliveryCallback, WorkerOutboxDispatch,
};
use aion_store::{EventStore, OutboxRow, OutboxStatus, OutboxStore};
use aion_store_haematite::HaematiteStore;
use engine_guard::EngineUnderTest;
use haematite::db::respond_to_inbound_writes;
use haematite::sync::membership::WriteMembership;
use haematite::sync::{DistributionEndpoint, SyncNodeId};
use haematite::{Database, DatabaseConfig};
use liminal_server::config::{ChannelDef, ServerConfig};
use liminal_server::server::connection::{ConnectionSupervisor, LiminalConnectionServices};
use liminal_server::server::listener::ServerListener;
use serde_json::json;
type TestError = Box<dyn Error + Send + Sync>;
fn liminal_row_dispatch(
registry: ConnectedWorkerRegistry,
callback: Arc<dyn OutboxDeliveryCallback>,
delivery_gate: DeliveryGate,
) -> WorkerOutboxDispatch {
let completion_fences = CompletionFences::default();
let heartbeat_tracker = HeartbeatTracker::new(Duration::from_secs(30));
let liminal_delivery: Arc<dyn WorkerTaskDelivery> = Arc::new(
LiminalTaskDelivery::new(Arc::new(
LiminalCompletionSource::new(callback)
.with_completion_fences(completion_fences.clone()),
))
.with_completion_tracking(heartbeat_tracker.clone(), registry.clone()),
);
WorkerOutboxDispatch::new(
ServerActivityDispatcher::new(registry)
.with_completion_fences(completion_fences)
.with_heartbeat_tracker(heartbeat_tracker)
.with_delivery_gate(delivery_gate)
.with_liminal_delivery(liminal_delivery),
)
}
type TestResult = Result<(), TestError>;
const NODE_NAMES: [&str; 3] = [
"lsub5-node-0@127.0.0.1",
"lsub5-node-1@127.0.0.1",
"lsub5-node-2@127.0.0.1",
];
const SHARD_COUNT: usize = 3;
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5);
const OP_TIMEOUT: Duration = Duration::from_secs(5);
const FAN_OUT: usize = 4;
const NAMESPACE: &str = "default";
const TASK_QUEUE: &str = "default";
const WORKER_IDENTITY: &str = "lsub5-survivor-worker";
const OUTBOX_MODULE: &str = "aion_outbox_fixture";
const OUTBOX_BEAM: &[u8] = include_bytes!("../../aion/tests/fixtures/aion_outbox_fixture.beam");
const OUTBOX_SOURCE: &[u8] = include_bytes!("../../aion/tests/fixtures/aion_outbox_fixture.erl");
const FAILOVER_DEADLINE: Duration = Duration::from_secs(40);
fn test_error(message: impl std::fmt::Display) -> TestError {
message.to_string().into()
}
fn loopback() -> Result<SocketAddr, TestError> {
"127.0.0.1:0".parse().map_err(test_error)
}
fn wait_until(timeout: Duration, mut predicate: impl FnMut() -> bool) -> bool {
let deadline = Instant::now() + timeout;
loop {
if predicate() {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(10));
}
}
fn membership(send_targets: &[&str]) -> WriteMembership {
WriteMembership {
total_nodes: NODE_NAMES.len(),
send_targets: send_targets
.iter()
.map(|name| SyncNodeId::from(*name))
.collect(),
}
}
fn fixture_package() -> Result<Package, TestError> {
let beams =
BeamSet::new(vec![BeamModule::new(OUTBOX_MODULE, OUTBOX_BEAM)]).map_err(test_error)?;
let manifest = Manifest {
entry_module: OUTBOX_MODULE.to_owned(),
entry_function: "collect_four".to_owned(),
input_schema: json!({ "type": "object" }),
output_schema: json!({}),
timeout: Some(Duration::from_secs(60)),
activities: vec![DeclaredActivity {
activity_type: "fixture_activity".to_owned(),
}],
version: ManifestVersion::new("stamped-by-builder"),
format_version: CURRENT_FORMAT_VERSION,
additional_workflows: Vec::new(),
};
let mut actions = Vec::new();
for activity_type in fan_out_activity_types() {
actions.push(fixture_action_contract(&activity_type)?);
}
let contract = PackageContract {
input_schema: json!({ "type": "object" }),
output_schema: json!({}),
workers: vec![WorkerContract {
task_queue: TASK_QUEUE.to_owned(),
actions,
}],
children: Vec::new(),
signals: Vec::new(),
additional_workflows: Vec::new(),
unscoped_activities: Vec::new(),
workloop: None,
};
let archive =
PackageBuilder::with_source(manifest, beams, [(OUTBOX_MODULE, OUTBOX_SOURCE.to_vec())])
.with_contract(contract)
.write_to_bytes()
.map_err(test_error)?;
Package::load_from_bytes(archive, ExtractionLimits::unbounded()).map_err(test_error)
}
fn fixture_action_contract(name: &str) -> Result<ActionContract, TestError> {
let descriptor =
aion_worker::activity::activity_descriptor::<serde_json::Value, serde_json::Value>(name)
.map_err(test_error)?;
Ok(ActionContract {
name: descriptor.name,
input_schema: descriptor.input_schema,
output_schema: descriptor.output_schema,
node: None,
timeout: None,
retry: None,
advisory: false,
agent: false,
body: None,
})
}
fn worker_result(ordinal: u64) -> serde_json::Value {
json!(format!("worker-{ordinal}"))
}
fn fan_out_activity_types() -> Vec<String> {
(0..FAN_OUT)
.map(|ordinal| format!("fan:{ordinal}"))
.collect()
}
async fn stage_fanout(
store: &Arc<HaematiteStore>,
workflow_id: &WorkflowId,
run_id: &RunId,
package: &Package,
) -> Result<(), TestError> {
let store_dyn: Arc<dyn EventStore> = Arc::clone(store) as Arc<dyn EventStore>;
let mut recorder = Recorder::new(workflow_id.clone(), store_dyn).with_run_id(run_id.clone());
recorder
.record_workflow_started(
chrono::Utc::now(),
WorkflowStartRecord {
workflow_type: OUTBOX_MODULE.to_owned(),
input: Payload::from_json(&json!({ "fixture": "fanout" })).map_err(test_error)?,
run_id: run_id.clone(),
parent_run_id: None,
parent_workflow_id: None,
package_version: PackageVersion::new(package.content_hash().to_string()),
},
)
.await
.map_err(test_error)?;
let items: Vec<FanOutItem> = (0..FAN_OUT as u64)
.map(|ordinal| {
Ok(FanOutItem {
ordinal,
namespace: NAMESPACE.to_owned(),
task_queue: DEFAULT_TASK_QUEUE.to_owned(),
node: None,
activity_type: format!("fan:{ordinal}"),
input: Payload::from_json(&json!("in")).map_err(test_error)?,
attempt: 1,
})
})
.collect::<Result<_, TestError>>()?;
recorder
.record_fan_out_dispatch(chrono::Utc::now(), &items)
.await
.map_err(test_error)?;
Ok(())
}
struct StubDispatcher {
fired: Arc<AtomicBool>,
}
impl ActivityDispatcher for StubDispatcher {
fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
self.fired.store(true, Ordering::SeqCst);
Err(format!(
"in-process activity dispatcher fired for {} — the durable outbox cutover is broken",
request.name,
))
}
}
struct Node {
store: Arc<HaematiteStore>,
event_store: Arc<haematite::EventStore>,
addr: SocketAddr,
name: &'static str,
responder: Option<JoinHandle<()>>,
running: Arc<AtomicBool>,
}
impl Node {
fn spawn(name: &'static str, dir: &Path, send_targets: &[&str]) -> Result<Self, TestError> {
let endpoint =
DistributionEndpoint::bind(name, loopback()?, 1, None).map_err(test_error)?;
let addr = endpoint.local_addr();
let database = Database::create(DatabaseConfig {
data_dir: dir.join("db"),
shard_count: SHARD_COUNT,
executor_threads: None,
distributed: None,
node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
})
.map_err(test_error)?
.with_distribution(endpoint);
let store = Arc::new(HaematiteStore::with_distribution(
database,
membership(send_targets),
OP_TIMEOUT,
name.to_owned(),
));
let event_store = Arc::clone(store.event_store());
let running = Arc::new(AtomicBool::new(true));
let responder_store = Arc::clone(&event_store);
let responder_running = Arc::clone(&running);
let responder = std::thread::spawn(move || {
while responder_running.load(Ordering::Relaxed) {
drop(respond_to_inbound_writes(
responder_store.database(),
Duration::from_millis(50),
));
}
});
Ok(Self {
store,
event_store,
addr,
name,
responder: Some(responder),
running,
})
}
fn database(&self) -> &Database {
self.event_store.database()
}
}
impl Drop for Node {
fn drop(&mut self) {
self.running.store(false, Ordering::Relaxed);
if let Some(handle) = self.responder.take() {
drop(handle.join());
}
}
}
fn link(from: &Node, to: &Node) -> TestResult {
let endpoint = from
.database()
.distribution()
.ok_or_else(|| test_error("dialing node has no endpoint"))?;
endpoint.add_peer(to.name, to.addr);
endpoint.connect(to.name).map_err(test_error)?;
if !wait_until(HANDSHAKE_TIMEOUT, || endpoint.is_connected(to.name)) {
return Err(test_error(format!(
"{} never linked to {}",
from.name, to.name
)));
}
Ok(())
}
fn link_both(a: &Node, b: &Node) -> TestResult {
link(a, b)?;
link(b, a)?;
Ok(())
}
fn workflow_id_for_shard(store: &HaematiteStore, shard: usize) -> WorkflowId {
loop {
let candidate = WorkflowId::new_v4();
if store.shard_for_workflow(&candidate) == shard {
return candidate;
}
}
}
struct RunningLiminalServer {
listener: Option<ServerListener>,
registry: aion_server::worker::ConnectedWorkerRegistry,
capacity_wake: Arc<tokio::sync::Notify>,
address: SocketAddr,
_admission_runtime: tokio::runtime::Runtime,
}
impl RunningLiminalServer {
fn start() -> Result<Self, TestError> {
let config = ServerConfig {
listen_address: "127.0.0.1:0".parse().map_err(test_error)?,
health_listen_address: reserve_loopback_port()?,
channels: Vec::<ChannelDef>::new(),
routing_rules: Vec::new(),
persistence_path: None,
cluster: None,
auth: None,
drain_timeout_ms: 30_000,
services: liminal_server::config::ServicesConfig::default(),
limits: liminal_server::config::LimitsConfig::default(),
websocket: None,
participant: None,
};
let admission_runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(test_error)?;
let capacity_wake = Arc::new(tokio::sync::Notify::new());
let registry = aion_server::worker::ConnectedWorkerRegistry::default()
.with_capacity_wake(Arc::clone(&capacity_wake));
let notifier = Arc::new(
LiminalConnectionNotifier::new(registry.clone()).with_admission(
NamespaceGuard::shared_engine(),
false,
admission_runtime.handle().clone(),
),
);
let services =
Arc::new(LiminalConnectionServices::from_config(&config).map_err(test_error)?);
let supervisor =
ConnectionSupervisor::with_services_and_notifier(services, notifier.clone())
.map_err(test_error)?;
if !notifier.bind_supervisor(supervisor.clone()) {
return Err(test_error("notifier supervisor was already bound"));
}
let listener = ServerListener::bind(&config, supervisor).map_err(test_error)?;
let address = listener.local_addr();
Ok(Self {
listener: Some(listener),
registry,
capacity_wake,
address,
_admission_runtime: admission_runtime,
})
}
fn has_worker(&self) -> Result<bool, TestError> {
for activity_type in fan_out_activity_types() {
let census = self
.registry
.pool_census(NAMESPACE, TASK_QUEUE, &activity_type, None)
.map_err(test_error)?;
if census.compatible_workers == 0 {
return Ok(false);
}
}
Ok(true)
}
fn wait_for_worker(&self) -> Result<(), TestError> {
if wait_until(HANDSHAKE_TIMEOUT, || self.has_worker().unwrap_or(false)) {
return Ok(());
}
Err(test_error(
"liminal server never registered the survivor worker for the pool",
))
}
fn shutdown(mut self) -> Result<(), TestError> {
if let Some(listener) = self.listener.take() {
listener.shutdown().map_err(test_error)?;
}
Ok(())
}
}
fn reserve_loopback_port() -> Result<SocketAddr, TestError> {
let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(test_error)?;
let address = listener.local_addr().map_err(test_error)?;
drop(listener);
Ok(address)
}
struct WorkerControl {
executions: AtomicUsize,
dispatch_seen: AtomicBool,
fanout_workflow: OnceLock<WorkflowId>,
released: AtomicBool,
}
impl WorkerControl {
fn new() -> Self {
Self {
executions: AtomicUsize::new(0),
dispatch_seen: AtomicBool::new(false),
fanout_workflow: OnceLock::new(),
released: AtomicBool::new(false),
}
}
fn set_fanout_workflow(&self, workflow_id: WorkflowId) -> Result<(), TestError> {
self.fanout_workflow
.set(workflow_id)
.map_err(|_| test_error("fan-out workflow id was already set"))
}
}
struct SurvivorWorker {
stop: Arc<AtomicBool>,
handle: Option<JoinHandle<()>>,
}
impl SurvivorWorker {
fn spawn(address: String, control: Arc<WorkerControl>) -> Self {
let stop = Arc::new(AtomicBool::new(false));
let thread_stop = Arc::clone(&stop);
let handle = std::thread::spawn(move || {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
eprintln!("survivor runtime build failed: {error}");
return;
}
};
runtime.block_on(async move {
if let Err(error) = serve_survivor(&address, &control, &thread_stop).await {
eprintln!("survivor worker ended with error: {error}");
}
});
});
Self {
stop,
handle: Some(handle),
}
}
fn stop(mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(handle) = self.handle.take() {
drop(handle.join());
}
}
}
async fn serve_survivor(
address: &str,
control: &Arc<WorkerControl>,
stop: &Arc<AtomicBool>,
) -> Result<(), TestError> {
use aion_worker::{ActivityRegistry, LiminalActivityWorker, WorkerConfig};
let mut registry = ActivityRegistry::new();
for ordinal in 0..FAN_OUT as u64 {
let activity_type = format!("fan:{ordinal}");
let control = Arc::clone(control);
registry = registry
.register_activity_with_contract(
activity_type,
move |_input: serde_json::Value, context| {
let control = Arc::clone(&control);
let is_fanout = control
.fanout_workflow
.get()
.is_some_and(|workflow_id| workflow_id == context.workflow_id());
Box::pin(async move {
control.executions.fetch_add(1, Ordering::SeqCst);
if is_fanout {
control.dispatch_seen.store(true, Ordering::SeqCst);
}
while !control.released.load(Ordering::SeqCst) {
tokio::time::sleep(Duration::from_millis(10)).await;
}
Ok(worker_result(ordinal))
})
},
)
.map_err(test_error)?;
}
let config = WorkerConfig::builder()
.endpoint("unused-direct-address")
.namespace(NAMESPACE)
.task_queue(TASK_QUEUE)
.identity(WORKER_IDENTITY)
.max_concurrency(2 * FAN_OUT)
.reconnect_initial_backoff(Duration::from_millis(5))
.reconnect_max_backoff(Duration::from_millis(20))
.reconnect_max_attempts(3)
.build()
.map_err(test_error)?;
let worker =
LiminalActivityWorker::connect(address, &config, Arc::new(registry)).map_err(test_error)?;
worker
.serve_until(|| stop.load(Ordering::SeqCst))
.await
.map_err(test_error)
}
#[derive(Debug, Default)]
struct NoopDeliveryCallback;
impl OutboxDeliveryCallback for NoopDeliveryCallback {
fn deliver_completion(
&self,
_workflow_id: &WorkflowId,
_activity_id: &aion_core::ActivityId,
_run_id: Option<&aion_core::RunId>,
_result: String,
) -> Result<bool, aion_server::ServerError> {
Ok(true)
}
fn deliver_failure(
&self,
_workflow_id: &WorkflowId,
_activity_id: &aion_core::ActivityId,
_run_id: Option<&aion_core::RunId>,
_reason: String,
) -> Result<bool, aion_server::ServerError> {
Ok(true)
}
}
struct OwnerServer {
runtime: tokio::runtime::Runtime,
dispatcher_shutdown: tokio::sync::watch::Sender<bool>,
}
impl OwnerServer {
fn spawn(
node: &Node,
liminal: &RunningLiminalServer,
dispatcher_config: OutboxDispatcherConfig,
) -> Result<Self, TestError> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.map_err(test_error)?;
let callback: Arc<dyn OutboxDeliveryCallback> = Arc::new(NoopDeliveryCallback);
let outbox_store: Arc<dyn OutboxStore> = Arc::clone(&node.store) as Arc<dyn OutboxStore>;
let dispatcher_builder = OutboxDispatcher::new(outbox_store, dispatcher_config);
let dispatch: Arc<dyn OutboxRowDispatch> = Arc::new(liminal_row_dispatch(
liminal.registry.clone(),
Arc::clone(&callback),
dispatcher_builder.delivery_gate(),
));
let dispatcher = dispatcher_builder
.with_dispatch(dispatch)
.with_delivery_callback(callback);
let (dispatcher_shutdown, shutdown_rx) = tokio::sync::watch::channel(false);
runtime.spawn(dispatcher.run(shutdown_rx));
Ok(Self {
runtime,
dispatcher_shutdown,
})
}
fn kill(self) {
let _: Result<(), _> = self.dispatcher_shutdown.send(true);
self.runtime.shutdown_timeout(Duration::from_secs(10));
}
}
struct Server {
runtime: tokio::runtime::Runtime,
store: Arc<HaematiteStore>,
engine: EngineUnderTest,
dispatcher_shutdown: tokio::sync::watch::Sender<bool>,
}
impl Server {
fn build(
node: &Node,
owned_shard: usize,
package: &Package,
liminal: &RunningLiminalServer,
fired: &Arc<AtomicBool>,
dispatcher_config: OutboxDispatcherConfig,
) -> Result<Self, TestError> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(3)
.enable_all()
.build()
.map_err(test_error)?;
let store_dyn: Arc<dyn EventStore> = Arc::clone(&node.store) as Arc<dyn EventStore>;
let fired = Arc::clone(fired);
let registry = liminal.registry.clone();
let package = package.clone();
let engine = runtime.block_on(async move {
EngineBuilder::new()
.stop_drain_timeout(std::time::Duration::from_secs(5))
.store_arc(store_dyn)
.in_memory_visibility()
.scheduler_threads(1)
.signal_router_factory(|runtime: Arc<RuntimeHandle>, handoff| {
Arc::new(ConcreteSignalRouter::new(runtime, handoff)) as Arc<dyn SignalRouter>
})
.outbox_enabled(true)
.activity_dispatcher(Arc::new(StubDispatcher { fired }))
.bootstrap_schedule_coordinator(false)
.owned_shards([owned_shard])
.load_workflows(package)
.build()
.await
.map_err(test_error)
})?;
let engine = Arc::new(engine);
let callback: Arc<dyn OutboxDeliveryCallback> =
Arc::new(ServerOutboxDeliveryCallback::new(Arc::clone(&engine)));
let outbox_store: Arc<dyn OutboxStore> = Arc::clone(&node.store) as Arc<dyn OutboxStore>;
let dispatcher_builder = OutboxDispatcher::new(outbox_store, dispatcher_config);
let liminal_dispatch: Arc<dyn OutboxRowDispatch> = Arc::new(liminal_row_dispatch(
registry,
Arc::clone(&callback),
dispatcher_builder.delivery_gate(),
));
let dispatcher = dispatcher_builder
.with_dispatch(liminal_dispatch)
.with_delivery_callback(callback)
.with_wake(Arc::clone(&liminal.capacity_wake));
let (dispatcher_shutdown, shutdown_rx) = tokio::sync::watch::channel(false);
runtime.spawn(dispatcher.run(shutdown_rx));
Ok(Self {
runtime,
store: Arc::clone(&node.store),
engine: EngineUnderTest::new(engine),
dispatcher_shutdown,
})
}
fn block_on<F: std::future::Future>(&self, future: F) -> F::Output {
self.runtime.block_on(future)
}
}
fn count_kind(history: &[Event], kind: fn(&Event) -> bool) -> usize {
history.iter().filter(|event| kind(event)).count()
}
fn is_workflow_started(event: &Event) -> bool {
matches!(event, Event::WorkflowStarted { .. })
}
fn is_workflow_completed(event: &Event) -> bool {
matches!(event, Event::WorkflowCompleted { .. })
}
fn is_scheduled_for(event: &Event, ordinal: u64) -> bool {
matches!(event, Event::ActivityScheduled { activity_id, .. }
if activity_id.sequence_position() == ordinal)
}
fn terminal_count_for(history: &[Event], ordinal: u64) -> usize {
history
.iter()
.filter(|event| match event {
Event::ActivityCompleted { activity_id, .. }
| Event::ActivityFailed { activity_id, .. }
| Event::ActivityCancelled { activity_id, .. } => {
activity_id.sequence_position() == ordinal
}
_ => false,
})
.count()
}
async fn read_history(
store: &Arc<HaematiteStore>,
workflow_id: &WorkflowId,
) -> Result<Vec<Event>, TestError> {
let store_dyn: Arc<dyn EventStore> = Arc::clone(store) as Arc<dyn EventStore>;
store_dyn
.read_history(workflow_id)
.await
.map_err(test_error)
}
#[test]
#[allow(clippy::too_many_lines)]
fn xnode_owner_kill_redrives_fanout_to_exactly_once_completion() -> TestResult {
println!("\n=== LSUB-5: cross-node owner-kill fan-out failover (exactly-once) ===");
let package = fixture_package()?;
let node_count = NODE_NAMES.len();
let dirs: Vec<tempfile::TempDir> = (0..node_count)
.map(|_| private_tempdir())
.collect::<Result<_, _>>()
.map_err(test_error)?;
let send_targets: Vec<Vec<&str>> = (0..node_count)
.map(|i| {
(0..node_count)
.filter(|&j| j != i)
.map(|j| NODE_NAMES[j])
.collect()
})
.collect();
let mut nodes: Vec<Option<Node>> = (0..node_count)
.map(|i| Node::spawn(NODE_NAMES[i], dirs[i].path(), &send_targets[i]).map(Some))
.collect::<Result<_, _>>()?;
for a in 0..node_count {
for b in (a + 1)..node_count {
link_both(node_ref(&nodes, a)?, node_ref(&nodes, b)?)?;
}
}
for (i, targets) in send_targets.iter().enumerate() {
let node = node_ref(&nodes, i)?;
node.database()
.acquire_shard_and_serve(i, &membership(targets), OP_TIMEOUT)
.map_err(test_error)?;
node.store.set_owned_shards([i]);
}
println!(
" 3-node cluster up: A owns shard 0 (dies), B owns shard 1 (survivor), C quorum-only."
);
let liminal = RunningLiminalServer::start()?;
let address = liminal.address.to_string();
let control = Arc::new(WorkerControl::new());
let survivor = SurvivorWorker::spawn(address.clone(), Arc::clone(&control));
liminal.wait_for_worker()?;
println!(" liminal server up; survivor worker registered for the fan-out pool.");
let fanout_workflow = workflow_id_for_shard(&node_ref(&nodes, 0)?.store, 0);
let witness_workflow = workflow_id_for_shard(&node_ref(&nodes, 1)?.store, 1);
control.set_fanout_workflow(fanout_workflow.clone())?;
let fanout_run = RunId::new_v4();
let fired_b = Arc::new(AtomicBool::new(false));
let dispatcher_config = OutboxDispatcherConfig {
poll_interval: Duration::from_millis(25),
batch_size: 16,
max_attempts: 8,
backoff_base: Duration::from_secs(120),
backoff_multiplier: 2,
backoff_max: Duration::from_secs(240),
};
let server_b = Server::build(
node_ref(&nodes, 1)?,
1,
&package,
&liminal,
&fired_b,
dispatcher_config,
)?;
let store_a = Arc::clone(&node_ref(&nodes, 0)?.store);
let mut owner = Some(OwnerServer::spawn(
node_ref(&nodes, 0)?,
&liminal,
dispatcher_config,
)?);
println!(" survivor B (engine) and owner A (shard owner + dispatcher) up.");
server_b.block_on(stage_fanout(
&store_a,
&fanout_workflow,
&fanout_run,
&package,
))?;
let witness_run = server_b
.block_on(server_b.engine.engine.start_workflow_with_id(
OUTBOX_MODULE,
Payload::from_json(&json!({ "fixture": "witness" })).map_err(test_error)?,
HashMap::new(),
NAMESPACE.to_owned(),
Some(witness_workflow.clone()),
None,
))
.map_err(test_error)?
.run_id()
.clone();
println!(" fan-out staged on shard 0 (durable cutover); witness started on shard 1.");
let staged = wait_until(Duration::from_secs(20), || {
server_b
.block_on(all_rows_present(&store_a, &fanout_workflow))
.unwrap_or(false)
});
assert!(
staged,
"the fan-out must stage all {FAN_OUT} outbox rows on shard 0"
);
println!(" all {FAN_OUT} fan-out rows staged Pending on shard 0 (durable cutover).");
let mid_dispatch = wait_until(Duration::from_secs(20), || {
control.dispatch_seen.load(Ordering::SeqCst)
});
assert!(
mid_dispatch,
"owner A's dispatcher must reach the worker MID-DISPATCH before the kill"
);
let claimed_before_kill = server_b.block_on(claimed_count(&store_a, &fanout_workflow))?;
assert!(
claimed_before_kill >= 1,
"at least one shard-0 row must be Claimed (in flight) when A is killed; got {claimed_before_kill}"
);
let executions_before_kill = control.executions.load(Ordering::SeqCst);
println!(
" MID-DISPATCH: worker received a dispatch; {claimed_before_kill} shard-0 row(s) Claimed, \
{executions_before_kill} execution(s) so far."
);
let store_b = Arc::clone(&server_b.store);
let mut supervisor = ClusterSupervisor::new(
Arc::clone(&store_b),
Arc::clone(&server_b.engine.engine),
vec![WatchedPeer {
name: NODE_NAMES[0].to_owned(),
owned_shards: vec![0],
}],
SupervisorConfig {
poll_interval: Duration::from_millis(20),
confirmations: 2,
},
);
assert!(supervisor.watches_any(), "supervisor must watch server A");
assert!(
store_b.peer_connected(NODE_NAMES[0]),
"server B must see server A connected before the kill"
);
let pre_kill = server_b.block_on(supervisor.tick());
assert!(pre_kill.is_empty(), "no adoption while server A is alive");
println!(" >>> killing owner A (drop dispatcher runtime, close endpoint) <<<");
control.released.store(true, Ordering::SeqCst);
owner
.take()
.ok_or_else(|| test_error("owner A already killed"))?
.kill();
drop(store_a);
let dead = nodes[0]
.take()
.ok_or_else(|| test_error("owner A node already gone"))?;
drop(dead);
assert!(
wait_until(Duration::from_secs(20), || !store_b
.peer_connected(NODE_NAMES[0])),
"server B must observe server A's replication link DROP after the kill"
);
println!(" server B observed server A's link DROP (peer_connected -> false).");
let first = server_b.block_on(supervisor.tick());
assert!(
first.is_empty(),
"debounce: first down-tick must not adopt yet"
);
let second = server_b.block_on(supervisor.tick());
assert_eq!(
second,
vec![NODE_NAMES[0].to_owned()],
"second consecutive down-tick must AUTO-adopt server A's shard 0"
);
println!(" server B AUTO-adopted shard 0 (debounced, no manual adopt).");
let owned_after = store_b.owned_shards().unwrap_or_default();
assert!(
owned_after.contains(&0) && owned_after.contains(&1),
"adoption must UNION shard 0 into B's owned scope (got {owned_after:?}) — \
this is the shared owned_shard_scope() the dispatcher's claim filters on"
);
println!(
" CRUX: B's shared claim scope now owns {owned_after:?} (shard 0 refreshed in place)."
);
let started = Instant::now();
let completed = wait_until(FAILOVER_DEADLINE, || {
server_b.block_on(async {
rows_all_done(&store_b, &fanout_workflow)
.await
.unwrap_or(false)
&& workflow_completed(&store_b, &fanout_workflow)
.await
.unwrap_or(false)
})
});
let elapsed = started.elapsed();
assert!(
completed,
"the fan-out must re-drive to completion on server B within {FAILOVER_DEADLINE:?} \
(elapsed {elapsed:?})"
);
println!(" failover completed in {elapsed:?}: all shard-0 rows Done, workflow Completed.");
let history = server_b.block_on(read_history(&store_b, &fanout_workflow))?;
assert_eq!(
count_kind(&history, is_workflow_started),
1,
"exactly one WorkflowStarted (idempotent recovery): {history:#?}"
);
for ordinal in 0..FAN_OUT as u64 {
let scheduled = history
.iter()
.filter(|event| is_scheduled_for(event, ordinal))
.count();
assert_eq!(
scheduled, 1,
"ordinal {ordinal} must have exactly one ActivityScheduled (no duplicate scheduling)"
);
}
for ordinal in 0..FAN_OUT as u64 {
assert_eq!(
terminal_count_for(&history, ordinal),
1,
"ordinal {ordinal} must have EXACTLY ONE terminal event: {history:#?}"
);
}
assert_eq!(
count_kind(&history, is_workflow_completed),
1,
"the fan-out workflow completes exactly once"
);
assert_eq!(
aion_core::status_from_events(&history),
WorkflowStatus::Completed,
"the fan-out workflow must be terminally Completed"
);
for ordinal in 0..FAN_OUT as u64 {
let key = OutboxRow::dispatch_key_for(&fanout_workflow, ordinal);
let status = server_b
.block_on(store_b.outbox_row_status(&key))
.map_err(test_error)?
.ok_or_else(|| test_error(format!("missing outbox row for ordinal {ordinal}")))?;
assert_eq!(
status,
OutboxStatus::Done,
"ordinal {ordinal}'s outbox row must end Done"
);
}
let total_executions = control.executions.load(Ordering::SeqCst);
assert!(
total_executions >= FAN_OUT,
"the activity must have executed at least once per ordinal; got {total_executions}"
);
assert!(
total_executions > FAN_OUT,
"the worker must have executed MORE than once per ordinal (A's lost wave + B's redelivery): \
got {total_executions} executions for {FAN_OUT} ordinals, each with exactly one terminal"
);
println!(
" ONE-TERMINAL PROVED: {FAN_OUT} ordinals, one terminal each; \
worker executed {total_executions} times (at-least-once, dedup -> exactly-once)."
);
let witness_done = wait_until(FAILOVER_DEADLINE, || {
server_b.block_on(async {
rows_all_done(&server_b.store, &witness_workflow)
.await
.unwrap_or(false)
&& workflow_completed(&server_b.store, &witness_workflow)
.await
.unwrap_or(false)
})
});
assert!(
witness_done,
"the witness workflow on shard 1 must complete, unaffected by the kill"
);
let witness_history = server_b.block_on(read_history(&server_b.store, &witness_workflow))?;
for ordinal in 0..FAN_OUT as u64 {
assert_eq!(
terminal_count_for(&witness_history, ordinal),
1,
"witness ordinal {ordinal} has exactly one terminal"
);
}
assert_eq!(
aion_core::status_from_events(&witness_history),
WorkflowStatus::Completed,
"the witness workflow must be Completed"
);
println!(" witness workflow on shard 1 completed, unaffected by the kill.");
let adopted = server_b
.block_on(server_b.engine.engine.result(&fanout_workflow, &fanout_run))
.map_err(test_error)?;
assert!(
adopted.is_ok(),
"the adopted fan-out run must resolve to a successful result"
);
let witness_result = server_b
.block_on(
server_b
.engine
.engine
.result(&witness_workflow, &witness_run),
)
.map_err(test_error)?;
assert!(
witness_result.is_ok(),
"the witness run must resolve to a successful result"
);
println!(
"=== LSUB-5 PROVED: owner killed mid-dispatch; survivor adopted, re-drove fan-out, \
exactly-once per ordinal ==="
);
let _: Result<(), _> = server_b.dispatcher_shutdown.send(true);
drop(supervisor);
server_b.engine.shutdown().map_err(test_error)?;
let Server {
runtime,
store,
engine,
..
} = server_b;
runtime.shutdown_timeout(Duration::from_secs(10));
drop(engine);
drop(store);
survivor.stop();
liminal.shutdown()?;
Ok(())
}
fn node_ref(nodes: &[Option<Node>], index: usize) -> Result<&Node, TestError> {
nodes
.get(index)
.and_then(Option::as_ref)
.ok_or_else(|| test_error(format!("node {index} is not live")))
}
async fn all_rows_present(
store: &Arc<HaematiteStore>,
workflow_id: &WorkflowId,
) -> Result<bool, TestError> {
for ordinal in 0..FAN_OUT as u64 {
let key = OutboxRow::dispatch_key_for(workflow_id, ordinal);
if store
.outbox_row_status(&key)
.await
.map_err(test_error)?
.is_none()
{
return Ok(false);
}
}
Ok(true)
}
async fn claimed_count(
store: &Arc<HaematiteStore>,
workflow_id: &WorkflowId,
) -> Result<usize, TestError> {
let mut claimed = 0;
for ordinal in 0..FAN_OUT as u64 {
let key = OutboxRow::dispatch_key_for(workflow_id, ordinal);
if store.outbox_row_status(&key).await.map_err(test_error)? == Some(OutboxStatus::Claimed) {
claimed += 1;
}
}
Ok(claimed)
}
async fn rows_all_done(
store: &Arc<HaematiteStore>,
workflow_id: &WorkflowId,
) -> Result<bool, TestError> {
for ordinal in 0..FAN_OUT as u64 {
let key = OutboxRow::dispatch_key_for(workflow_id, ordinal);
if store.outbox_row_status(&key).await.map_err(test_error)? != Some(OutboxStatus::Done) {
return Ok(false);
}
}
Ok(true)
}
async fn workflow_completed(
store: &Arc<HaematiteStore>,
workflow_id: &WorkflowId,
) -> Result<bool, TestError> {
let history = read_history(store, workflow_id).await?;
Ok(aion_core::status_from_events(&history) == WorkflowStatus::Completed)
}
fn private_tempdir() -> std::io::Result<tempfile::TempDir> {
let dir = tempfile::tempdir()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700))?;
}
Ok(dir)
}