#![cfg(feature = "liminal-transport")]
use std::error::Error;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use aion_core::{ActivityId, RunId, WorkflowId};
use aion_server::ServerError;
use aion_server::worker::{
ConnectedWorkerRegistry, LiminalConnectionNotifier, OutboxDeliveryCallback, OutboxRowDispatch,
RegistryLiminalDispatch,
};
use aion_store::{ClaimScope, OutboxRow, OutboxStatus, OutboxStore};
use aion_store_libsql::LibSqlStore;
use aion_worker::{ActivityRegistry, LiminalActivityWorker, WorkerConfig};
use chrono::Utc;
use liminal_server::config::{ChannelDef, ServerConfig};
use liminal_server::server::connection::ConnectionSupervisor;
use liminal_server::server::listener::ServerListener;
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use uuid::Uuid;
type TestError = Box<dyn Error + Send + Sync>;
type CompletionRecord = (WorkflowId, ActivityId, Option<RunId>, String);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const NAMESPACE: &str = "remote";
const TASK_QUEUE: &str = "gpu";
const NODE: &str = "box-7";
const ACTIVITY_TYPE: &str = "charge-card";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ChargeInput {
amount: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ChargeOutput {
charged: bool,
amount: u64,
}
fn test_error(message: impl std::fmt::Display) -> TestError {
message.to_string().into()
}
#[derive(Debug, Default)]
struct RecordingCallback {
completions: Mutex<Vec<CompletionRecord>>,
}
impl OutboxDeliveryCallback for RecordingCallback {
fn deliver_completion(
&self,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
run_id: Option<&RunId>,
result: String,
) -> Result<bool, ServerError> {
if let Ok(mut completions) = self.completions.lock() {
completions.push((
workflow_id.clone(),
activity_id.clone(),
run_id.cloned(),
result,
));
}
Ok(true)
}
fn deliver_failure(
&self,
_workflow_id: &WorkflowId,
_activity_id: &ActivityId,
_run_id: Option<&RunId>,
_reason: String,
) -> Result<bool, ServerError> {
Ok(true)
}
}
struct RunningServer {
listener: Option<ServerListener>,
registry: ConnectedWorkerRegistry,
address: SocketAddr,
}
impl RunningServer {
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,
drain_timeout_ms: 30_000,
};
let registry = ConnectedWorkerRegistry::default();
let notifier = Arc::new(LiminalConnectionNotifier::new(registry.clone()));
let supervisor = build_supervisor_with_notifier(&config, notifier.clone())?;
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,
address,
})
}
fn wait_for_registered_worker(&self) -> Result<(), TestError> {
self.wait_until(true, "server never registered the in-band worker")
}
fn wait_for_deregistered_worker(&self) -> Result<(), TestError> {
self.wait_until(false, "server never deregistered the worker on disconnect")
}
fn wait_until(&self, present: bool, on_timeout: &str) -> Result<(), TestError> {
let deadline = Instant::now() + CONNECT_TIMEOUT;
while Instant::now() < deadline {
let selected = self
.registry
.select_worker(NAMESPACE, TASK_QUEUE, ACTIVITY_TYPE, Some(NODE))
.map_err(test_error)?;
if selected.is_some() == present {
return Ok(());
}
std::thread::sleep(Duration::from_millis(10));
}
Err(test_error(on_timeout))
}
fn shutdown(mut self) -> Result<(), TestError> {
if let Some(listener) = self.listener.take() {
listener.shutdown().map_err(test_error)?;
}
Ok(())
}
}
fn build_supervisor_with_notifier(
config: &ServerConfig,
notifier: Arc<LiminalConnectionNotifier>,
) -> Result<ConnectionSupervisor, TestError> {
use liminal_server::server::connection::LiminalConnectionServices;
let services = Arc::new(LiminalConnectionServices::from_config(config).map_err(test_error)?);
ConnectionSupervisor::with_services_and_notifier(services, notifier).map_err(test_error)
}
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)
}
fn worker_registry(executions: Arc<AtomicUsize>) -> Result<Arc<ActivityRegistry>, TestError> {
let registry = ActivityRegistry::new()
.register_activity(ACTIVITY_TYPE, move |input: ChargeInput, _context| {
let executions = Arc::clone(&executions);
Box::pin(async move {
executions.fetch_add(1, Ordering::SeqCst);
Ok(ChargeOutput {
charged: true,
amount: input.amount,
})
})
})
.map_err(test_error)?;
Ok(Arc::new(registry))
}
fn worker_config() -> Result<WorkerConfig, TestError> {
WorkerConfig::builder()
.endpoint("unused-direct-address")
.namespace(NAMESPACE)
.task_queue(TASK_QUEUE)
.node(NODE)
.identity("lsub-l2-worker")
.max_concurrency(1)
.reconnect_initial_backoff(Duration::from_millis(5))
.reconnect_max_backoff(Duration::from_millis(20))
.reconnect_max_attempts(3)
.build()
.map_err(test_error)
}
struct WorkerThread {
stop: Arc<std::sync::atomic::AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
}
impl WorkerThread {
fn spawn(
address: String,
config: WorkerConfig,
registry: Arc<ActivityRegistry>,
) -> Self {
let stop = Arc::new(std::sync::atomic::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!("worker runtime build failed: {error}");
return;
}
};
runtime.block_on(async move {
let worker = match LiminalActivityWorker::connect(&address, &config, registry) {
Ok(worker) => worker,
Err(error) => {
eprintln!("worker connect failed: {error}");
return;
}
};
if let Err(error) = worker
.serve_until(|| thread_stop.load(Ordering::SeqCst))
.await
{
eprintln!("worker serve loop 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() {
handle.join().ok();
}
}
}
fn pending_row(
namespace: &str,
task_queue: &str,
node: Option<&str>,
ordinal: u64,
) -> Result<OutboxRow, TestError> {
let workflow_id = WorkflowId::new(Uuid::new_v4());
let dispatch_key = format!("{workflow_id}:{ordinal}");
let input =
aion_core::Payload::from_json(&serde_json::json!({ "amount": 42 })).map_err(test_error)?;
Ok(OutboxRow {
dispatch_key,
workflow_id,
ordinal,
run_id: Some(RunId::new(Uuid::new_v4())),
namespace: namespace.to_owned(),
task_queue: task_queue.to_owned(),
node: node.map(ToOwned::to_owned),
activity_type: ACTIVITY_TYPE.to_owned(),
input,
status: OutboxStatus::Pending,
attempt: 0,
visible_after: Utc::now(),
claimed_at: None,
})
}
async fn open_store(name: &str) -> Result<Arc<LibSqlStore>, TestError> {
let nanos = Instant::now().elapsed().as_nanos();
let path = std::env::temp_dir().join(format!(
"aion-lsub1-{name}-{}-{nanos}.db",
std::process::id()
));
LibSqlStore::open(path)
.await
.map(Arc::new)
.map_err(test_error)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn xnode_inband_dispatch_routes_executes_and_completes() -> Result<(), TestError> {
let server = RunningServer::start()?;
let address = server.address.to_string();
let executions = Arc::new(AtomicUsize::new(0));
let registry_for_worker = worker_registry(Arc::clone(&executions))?;
let worker = WorkerThread::spawn(address.clone(), worker_config()?, registry_for_worker);
server.wait_for_registered_worker()?;
let registry = server.registry.clone();
let store = open_store("dispatch").await?;
let row = pending_row(NAMESPACE, TASK_QUEUE, Some(NODE), 0)?;
store
.append_outbox_batch(std::slice::from_ref(&row))
.await
.map_err(test_error)?;
let scope = ClaimScope::new(NAMESPACE, TASK_QUEUE).with_node(Some(NODE.to_owned()));
let claimed = store
.claim_outbox_rows_scoped(&scope, 16)
.await
.map_err(test_error)?;
let claimed_row = claimed
.into_iter()
.find(|candidate| candidate.dispatch_key == row.dispatch_key)
.ok_or_else(|| test_error("scoped claim did not return the staged row"))?;
let callback = Arc::new(RecordingCallback::default());
let dispatch = RegistryLiminalDispatch::new(
registry.clone(),
Arc::clone(&callback) as Arc<dyn OutboxDeliveryCallback>,
);
dispatch
.dispatch(&claimed_row)
.await
.map_err(|error| test_error(format!("cross-node dispatch returned Err: {error}")))?;
assert_eq!(
executions.load(Ordering::SeqCst),
1,
"the remote worker must have executed the dispatched activity exactly once"
);
{
let completions = callback
.completions
.lock()
.map_err(|_| test_error("completions lock poisoned"))?;
assert_eq!(completions.len(), 1, "exactly one terminal completion");
let (workflow_id, activity_id, run_id, result) = completions
.first()
.ok_or_else(|| test_error("no completion recorded"))?;
assert_eq!(workflow_id, &row.workflow_id);
assert_eq!(
activity_id,
&ActivityId::from_sequence_position(row.ordinal)
);
assert_eq!(
run_id, &row.run_id,
"run_id survived the liminal round trip"
);
let output: ChargeOutput = serde_json::from_str(result).map_err(test_error)?;
assert!(output.charged, "the handler ran and charged");
assert_eq!(output.amount, 42, "the handler saw the dispatched input");
}
worker.stop();
server.wait_for_deregistered_worker()?;
assert!(
registry
.select_worker(NAMESPACE, TASK_QUEUE, ACTIVITY_TYPE, Some(NODE))
.map_err(test_error)?
.is_none(),
"the disconnected worker must be deregistered from the registry"
);
server.shutdown()?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn dispatch_for_a_different_pool_is_not_delivered() -> Result<(), TestError> {
let server = RunningServer::start()?;
let address = server.address.to_string();
let executions = Arc::new(AtomicUsize::new(0));
let registry_for_worker = worker_registry(Arc::clone(&executions))?;
let worker = WorkerThread::spawn(address.clone(), worker_config()?, registry_for_worker);
server.wait_for_registered_worker()?;
let registry = server.registry.clone();
let other_row = pending_row("other", "cpu", None, 0)?;
let callback = Arc::new(RecordingCallback::default());
let dispatch = RegistryLiminalDispatch::new(
registry.clone(),
Arc::clone(&callback) as Arc<dyn OutboxDeliveryCallback>,
);
let result = dispatch.dispatch(&other_row).await;
assert!(
result.is_err(),
"a dispatch for a pool with no matching worker must return Err so the outbox retries"
);
assert_eq!(
executions.load(Ordering::SeqCst),
0,
"the worker must not execute a dispatch for a pool it does not serve"
);
assert_eq!(
callback
.completions
.lock()
.map_err(|_| test_error("completions lock poisoned"))?
.len(),
0,
"no completion is recorded for an undelivered dispatch"
);
worker.stop();
server.shutdown()?;
Ok(())
}