use std::collections::BTreeMap;
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use aion::{ActivityDispatch, ActivityDispatcher};
use aion_core::{ActivityId, ContentType, Payload, RunId, WorkflowId};
use aion_proto::{ProtoActivityId, ProtoActivityTask, ProtoPayload, ProtoWorkflowId};
use dashmap::DashMap;
use super::dispatch::{ActivityCompletion, ActivityCompletionOutcome, ActivityCompletionSink};
use super::envelope::{CompletionFences, CompletionToken, idempotency_key};
use super::heartbeat::{HeartbeatTracker, InFlightActivity};
use super::queue_service::{
DeliveryRefusal, ExpiredClock, PARK_POLL_INTERVAL, PoolCensus, QueueDeclarationSource,
QueueServiceConfig, QueueServiceReason, QueueServiceState, SelectionRefusal, ServiceAddress,
ServiceWait, WorkerUnavailable, deliver_within_schedule_to_start, select_worker_or_refuse,
};
use super::registry::{
ConnectedWorkerRegistry, WorkerDelivery, WorkerHandle, WorkerId, WorkerMessage,
};
use crate::error::ServerError;
use crate::shutdown::DrainState;
use tracing::info_span;
type SyncSender = std::sync::mpsc::SyncSender<Result<String, String>>;
type SyncReceiver = std::sync::mpsc::Receiver<Result<String, String>>;
type PendingActivityKey = (WorkflowId, ActivityId);
pub trait OutboxDeliveryCallback: Send + Sync {
fn deliver_completion(
&self,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
run_id: Option<&RunId>,
result: String,
) -> Result<bool, ServerError>;
fn deliver_failure(
&self,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
run_id: Option<&RunId>,
reason: String,
) -> Result<bool, ServerError>;
}
#[derive(Clone, Default)]
pub struct PendingActivities {
pending: Arc<DashMap<PendingActivityKey, SyncSender>>,
completion_fences: CompletionFences,
outbox_delivery: Arc<OnceLock<Arc<dyn OutboxDeliveryCallback>>>,
transport_losses: super::transport_loss::TransportLossLedger,
}
impl std::fmt::Debug for PendingActivities {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PendingActivities")
.field("pending", &self.pending.len())
.field("completion_fences", &self.completion_fences)
.field(
"outbox_delivery_installed",
&self.outbox_delivery.get().is_some(),
)
.field("transport_losses", &self.transport_losses)
.finish()
}
}
impl PendingActivities {
fn insert(
&self,
workflow_id: WorkflowId,
activity_id: ActivityId,
) -> Result<(CompletionToken, SyncReceiver), ServerError> {
let completion_token = self.completion_fences.issue(&workflow_id, &activity_id)?;
let (tx, rx) = std::sync::mpsc::sync_channel(1);
self.pending.insert((workflow_id, activity_id), tx);
Ok((completion_token, rx))
}
#[must_use]
pub fn completion_fences(&self) -> CompletionFences {
self.completion_fences.clone()
}
#[cfg(test)]
pub(crate) fn insert_for_test(
&self,
workflow_id: WorkflowId,
activity_id: ActivityId,
) -> Result<(CompletionToken, SyncReceiver), ServerError> {
self.insert(workflow_id, activity_id)
}
pub fn set_outbox_delivery(&self, callback: Arc<dyn OutboxDeliveryCallback>) {
if self.outbox_delivery.set(callback).is_err() {
tracing::warn!("outbox delivery callback already installed; ignoring duplicate set");
}
}
fn complete(
&self,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
run_id: Option<&RunId>,
result: Result<String, String>,
) -> bool {
let matched = self
.pending
.remove(&(workflow_id.clone(), activity_id.clone()));
if let Some((_, sender)) = matched {
return sender.send(result).is_ok();
}
let Some(callback) = self.outbox_delivery.get() else {
return false;
};
let outcome = match result {
Ok(payload) => callback.deliver_completion(workflow_id, activity_id, run_id, payload),
Err(reason) => callback.deliver_failure(workflow_id, activity_id, run_id, reason),
};
match outcome {
Ok(true) => true,
Ok(false) => {
tracing::debug!(
workflow_id = %workflow_id,
activity_id = %activity_id,
"unmatched outbox completion for a workflow that is not currently live; \
recovery will re-arm it"
);
false
}
Err(error) => {
tracing::warn!(
workflow_id = %workflow_id,
activity_id = %activity_id,
%error,
"failed to deliver unmatched outbox completion to the live workflow"
);
false
}
}
}
fn complete_fenced(
&self,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
run_id: Option<&RunId>,
completion_token: &CompletionToken,
result: Result<String, String>,
) -> Result<bool, ServerError> {
self.completion_fences
.accept(workflow_id, activity_id, completion_token)
.inspect_err(|error| {
tracing::warn!(
workflow_id = %workflow_id,
activity_id = %activity_id,
%error,
"activity completion rejected by execution-generation fence"
);
})?;
let transport_domain = result
.as_ref()
.err()
.is_some_and(|reason| super::transport_loss::is_transport_domain_reason(reason));
if !transport_domain {
if let Err(error) = self.transport_losses.clear(workflow_id, activity_id) {
tracing::warn!(
workflow_id = %workflow_id,
activity_id = %activity_id,
%error,
"failed to retire the transport-loss budget for a resolved activity"
);
}
}
Ok(self.complete(workflow_id, activity_id, run_id, result))
}
#[must_use]
pub fn with_heartbeat_window(mut self, heartbeat_window: std::time::Duration) -> Self {
self.transport_losses = super::transport_loss::TransportLossLedger::new(heartbeat_window);
self
}
#[must_use]
pub const fn transport_losses(&self) -> &super::transport_loss::TransportLossLedger {
&self.transport_losses
}
fn classify_worker_loss(
&self,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
worker_id: crate::worker::registry::WorkerId,
) -> String {
let detail = super::transport_loss::worker_lost_detail(worker_id);
match self
.transport_losses
.record_loss(workflow_id, activity_id, &detail)
{
Ok(verdict) => {
if verdict.exhausted {
tracing::error!(
operation = "activity_complete",
workflow_id = %workflow_id,
activity_id = %activity_id,
worker_id = ?worker_id,
error_type = "TransportExhausted",
losses = verdict.losses,
budget_ms = self.transport_losses.budget().as_millis(),
"activity abandoned: the transport kept losing its worker past the \
transport-loss budget"
);
} else {
tracing::warn!(
operation = "activity_complete",
workflow_id = %workflow_id,
activity_id = %activity_id,
worker_id = ?worker_id,
error_type = "WorkerLost",
losses = verdict.losses,
budget_ms = self.transport_losses.budget().as_millis(),
"worker lost before reporting an activity result; the activity never ran \
and will be re-dispatched attempt-neutrally"
);
}
verdict.reason
}
Err(error) => {
tracing::error!(
workflow_id = %workflow_id,
activity_id = %activity_id,
%error,
"transport-loss ledger is unreadable; abandoning the activity rather than \
re-dispatching it without a budget"
);
format!(
"{}{detail} (transport-loss budget unreadable: {error})",
super::transport_loss::TRANSPORT_EXHAUSTED_REASON_PREFIX
)
}
}
}
}
impl ActivityCompletionSink for PendingActivities {
fn park_activity(
&self,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
) -> Result<(), ServerError> {
self.completion_fences
.revoke_current(workflow_id, activity_id)?;
let matched = self
.pending
.remove(&(workflow_id.clone(), activity_id.clone()));
if let Some((_, sender)) = matched {
let _ = sender.send(Err(aion::PARKED_ACTIVITY_REASON.to_owned()));
}
Ok(())
}
fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError> {
let result = match completion.outcome {
ActivityCompletionOutcome::Succeeded(payload) => {
payload_to_string(&payload).map_err(|reason| {
tracing::error!(
operation = "activity_complete",
workflow_id = %completion.workflow_id,
activity_id = %completion.activity_id,
error_type = "ActivityResultDecode",
%reason,
"activity completion failed"
);
ServerError::worker_dispatch("", "", format!("payload decode: {reason}"))
})?
}
ActivityCompletionOutcome::Failed(error) => {
let prefix = if error.is_retryable() {
"retryable"
} else {
"terminal"
};
tracing::error!(
operation = "activity_complete",
workflow_id = %completion.workflow_id,
activity_id = %completion.activity_id,
error_type = "ActivityFailed",
error_kind = prefix,
reason = %error.message,
"activity completion failed"
);
Err(format!("{prefix}:{}", error.message))
}
ActivityCompletionOutcome::WorkerLost { worker_id } => Err(self.classify_worker_loss(
&completion.workflow_id,
&completion.activity_id,
worker_id,
)),
};
self.complete_fenced(
&completion.workflow_id,
&completion.activity_id,
completion.run_id.as_ref(),
&completion.completion_token,
result,
)?;
Ok(())
}
}
fn payload_to_string(payload: &Payload) -> Result<Result<String, String>, String> {
match payload.content_type() {
ContentType::Json => String::from_utf8(payload.bytes().to_vec())
.map(Ok)
.map_err(|_| "activity result payload is not valid UTF-8".to_owned()),
}
}
pub struct WorkerActivityDispatcher {
registry: ConnectedWorkerRegistry,
namespace: String,
pending: PendingActivities,
heartbeat_tracker: HeartbeatTracker,
drain_state: DrainState,
tokio_handle: Option<tokio::runtime::Handle>,
attempt_owners: Option<super::intervention::AttemptOwnerIndex>,
queue_service: QueueServiceConfig,
queue_declarations: QueueDeclarationSource,
queue_state: QueueServiceState,
}
impl std::fmt::Debug for WorkerActivityDispatcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WorkerActivityDispatcher")
.field("namespace", &self.namespace)
.finish_non_exhaustive()
}
}
impl WorkerActivityDispatcher {
#[must_use]
pub fn new(
registry: ConnectedWorkerRegistry,
namespace: impl Into<String>,
heartbeat_tracker: HeartbeatTracker,
) -> Self {
Self {
registry,
namespace: namespace.into(),
pending: PendingActivities::default(),
heartbeat_tracker,
drain_state: DrainState::default(),
tokio_handle: None,
attempt_owners: None,
queue_service: QueueServiceConfig::default(),
queue_declarations: QueueDeclarationSource::default(),
queue_state: QueueServiceState::default(),
}
}
#[must_use]
pub fn with_queue_service(mut self, queue_service: QueueServiceConfig) -> Self {
self.queue_service = queue_service;
self
}
#[must_use]
pub fn with_queue_declarations(mut self, queue_declarations: QueueDeclarationSource) -> Self {
self.queue_declarations = queue_declarations;
self
}
#[must_use]
pub fn with_queue_state(mut self, queue_state: QueueServiceState) -> Self {
self.queue_state = queue_state;
self
}
#[must_use]
pub fn with_attempt_owners(
mut self,
attempt_owners: super::intervention::AttemptOwnerIndex,
) -> Self {
self.attempt_owners = Some(attempt_owners);
self
}
#[must_use]
pub fn with_pending(mut self, pending: PendingActivities) -> Self {
self.pending = pending;
self
}
#[must_use]
pub fn with_drain_state(mut self, drain_state: DrainState) -> Self {
self.drain_state = drain_state;
self
}
#[must_use]
pub fn with_tokio_handle(mut self, tokio_handle: tokio::runtime::Handle) -> Self {
self.tokio_handle = Some(tokio_handle);
self
}
}
impl WorkerActivityDispatcher {
fn ensure_accepting(
&self,
namespace: &str,
activity_type: &str,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
worker_id: Option<WorkerId>,
) -> Result<(), String> {
self.drain_state
.ensure_accepting(namespace, activity_type)
.map_err(|error| {
log_worker_error(
"WorkerDispatch",
namespace,
activity_type,
workflow_id,
activity_id,
worker_id,
&error.to_string(),
);
aion::PARKED_ACTIVITY_REASON.to_owned()
})
}
fn select_worker_or_wait(
&self,
address: &ServiceAddress,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
) -> Result<WorkerHandle, String> {
let wait = ServiceWait {
registry: &self.registry,
declarations: &self.queue_declarations,
config: &self.queue_service,
state: &self.queue_state,
address,
workflow_id,
activity_id,
};
let mut accepting = || {
self.ensure_accepting(
&address.namespace,
&address.activity_type,
workflow_id,
activity_id,
None,
)
};
let mut park = |budget: Option<Duration>| self.park_for_worker(budget);
select_worker_or_refuse(&wait, &mut accepting, &mut park).map_err(|refusal| {
let reason = refusal.reason_string();
if !matches!(refusal, SelectionRefusal::NotAccepting { .. }) {
let error_type = match refusal {
SelectionRefusal::Unavailable(_) => "WorkerUnavailable",
_ => "WorkerRegistry",
};
log_worker_error(
error_type,
&address.namespace,
&address.activity_type,
workflow_id,
activity_id,
None,
&reason,
);
}
reason
})
}
fn park_for_worker(&self, budget: Option<Duration>) {
let handle = self
.tokio_handle
.clone()
.or_else(|| tokio::runtime::Handle::try_current().ok());
let Some(handle) = handle else {
std::thread::sleep(
budget.map_or(PARK_POLL_INTERVAL, |budget| budget.min(PARK_POLL_INTERVAL)),
);
return;
};
handle.block_on(async {
let arrival = async {
tokio::select! {
() = self.registry.wait_for_worker() => {}
() = self.drain_state.wait_for_drain() => {}
}
};
match budget {
None => arrival.await,
Some(budget) => {
drop(tokio::time::timeout(budget, arrival).await);
}
}
});
}
fn track_worker_task(
&self,
worker_id: WorkerId,
activity_type: &str,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
attempt: u32,
completion_token: CompletionToken,
) -> Result<(), String> {
self.heartbeat_tracker
.track_task(
worker_id,
InFlightActivity {
workflow_id: workflow_id.clone(),
activity_id: activity_id.clone(),
attempt,
completion_token,
},
Instant::now(),
)
.map_err(|error| {
let reason = error.to_string();
log_worker_error(
"WorkerHeartbeatTracker",
&self.namespace,
activity_type,
workflow_id,
activity_id,
Some(worker_id),
&reason,
);
reason
})
}
fn cleanup_activity(
&self,
worker_id: WorkerId,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
completion_token: &CompletionToken,
) {
self.pending
.pending
.remove(&(workflow_id.clone(), activity_id.clone()));
if let Err(error) =
self.pending
.completion_fences
.revoke(workflow_id, activity_id, completion_token)
{
tracing::error!(
workflow_id = %workflow_id,
activity_id = %activity_id,
%error,
"failed to revoke undelivered activity generation"
);
}
let _ = self
.heartbeat_tracker
.complete_task(worker_id, workflow_id, activity_id);
self.drain_state.notify_activity_drained();
}
fn send_activity_task(
&self,
worker: &WorkerHandle,
task: ProtoActivityTask,
address: &ServiceAddress,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
completion_token: &CompletionToken,
) -> Result<(), String> {
match worker.delivery() {
WorkerDelivery::Grpc(sender) => {
let worker_id = worker.id();
let mut accepting = || {
self.ensure_accepting(
&address.namespace,
&address.activity_type,
workflow_id,
activity_id,
Some(worker_id),
)
};
let handed_over = deliver_within_schedule_to_start(
sender,
WorkerMessage::ActivityTask(Box::new(task)),
self.queue_service.schedule_to_start_timeout,
&mut accepting,
);
let Err(refusal) = handed_over else {
return Ok(());
};
self.cleanup_activity(worker_id, workflow_id, activity_id, completion_token);
let (error_type, reason) = self.hand_off_failure(&refusal, address);
if !matches!(refusal, DeliveryRefusal::NotAccepting { .. }) {
log_worker_error(
error_type,
&address.namespace,
&address.activity_type,
workflow_id,
activity_id,
Some(worker_id),
&reason,
);
}
Err(reason)
}
#[cfg(feature = "liminal-transport")]
WorkerDelivery::Liminal(delivery) => self.send_liminal_activity_task(
worker.id(),
delivery,
task,
&address.activity_type,
workflow_id,
activity_id,
),
}
}
fn hand_off_failure(
&self,
refusal: &DeliveryRefusal,
address: &ServiceAddress,
) -> (&'static str, String) {
match refusal {
DeliveryRefusal::Saturated { waited } => {
let census = self
.registry
.pool_census(
&address.namespace,
&address.task_queue,
&address.activity_type,
address.node.as_deref(),
)
.unwrap_or_else(|error| {
tracing::error!(
namespace = %address.namespace,
task_queue = %address.task_queue,
activity_type = %address.activity_type,
%error,
"poller census failed while reporting a saturated queue; \
the refusal carries an empty census"
);
PoolCensus::default()
});
let unavailable = WorkerUnavailable {
reason: QueueServiceReason::Saturated,
clock: Some(ExpiredClock::ScheduleToStart),
waited: *waited,
address: address.clone(),
census,
};
("WorkerUnavailable", unavailable.reason_string())
}
DeliveryRefusal::Full => (
"WorkerChannelClosed",
"worker task channel full or closed: no available capacity".to_owned(),
),
DeliveryRefusal::Closed => (
"WorkerChannelClosed",
"worker task channel full or closed: channel closed".to_owned(),
),
DeliveryRefusal::NotAccepting { reason } => ("WorkerDispatch", reason.clone()),
}
}
#[cfg(feature = "liminal-transport")]
fn send_liminal_activity_task(
&self,
worker_id: WorkerId,
delivery: &super::liminal_transport::LiminalWorkerDelivery,
task: ProtoActivityTask,
activity_type: &str,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
) -> Result<(), String> {
let completion_token =
CompletionToken::from_wire(workflow_id, activity_id, task.completion_token.clone())
.map_err(|error| error.to_string())?;
let heartbeat_window_ms =
u64::try_from(self.heartbeat_tracker.heartbeat_window().as_millis())
.unwrap_or(u64::MAX);
let attempt = task.attempt;
let run_id = task
.run_id
.map(RunId::try_from)
.transpose()
.map_err(|error| error.to_string())?
.ok_or_else(|| {
"activity task run id is missing; refusing to dispatch an unidentified run"
.to_owned()
})?;
let request = super::liminal_transport::DispatchRequest {
activity_type: activity_type.to_owned(),
workflow_id: workflow_id.clone(),
ordinal: activity_id.sequence_position(),
run_id: Some(run_id.clone()),
attempt,
completion_token: task.completion_token,
idempotency_key: task.idempotency_key,
labels: task.labels.into_iter().collect(),
heartbeat_window_ms,
input: task.input.map(|payload| payload.bytes).unwrap_or_default(),
};
let awaiter = match delivery.push_dispatch(&request) {
Ok(awaiter) => awaiter,
Err(error) => {
let reason = format!("worker liminal push failed: {error}");
self.cleanup_activity(worker_id, workflow_id, activity_id, &completion_token);
log_worker_error(
"WorkerChannelClosed",
&self.namespace,
activity_type,
workflow_id,
activity_id,
Some(worker_id),
&reason,
);
return Err(reason);
}
};
let owner_binding = self.attempt_owners.as_ref().map(|owners| {
super::liminal_transport::AttemptOwnerGuard::bind(
owners.clone(),
super::intervention::AttemptKey::new(
workflow_id.clone(),
run_id.clone(),
activity_id.clone(),
attempt,
),
worker_id,
)
});
self.spawn_liminal_reply_router(
worker_id,
awaiter,
workflow_id,
activity_id,
&completion_token,
owner_binding,
);
Ok(())
}
#[cfg(feature = "liminal-transport")]
fn spawn_liminal_reply_router(
&self,
worker_id: WorkerId,
awaiter: liminal_server::server::connection::PushReplyAwaiter,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
completion_token: &CompletionToken,
owner_binding: Option<super::liminal_transport::AttemptOwnerGuard>,
) {
let pending = self.pending.clone();
let heartbeat_tracker = self.heartbeat_tracker.clone();
let drain_state = self.drain_state.clone();
let workflow_id = workflow_id.clone();
let activity_id = activity_id.clone();
let completion_token = completion_token.clone();
std::thread::spawn(move || {
let _owner_binding = owner_binding;
route_liminal_reply(
&pending,
&heartbeat_tracker,
&drain_state,
&awaiter,
(worker_id, &workflow_id, &activity_id, &completion_token),
);
});
}
fn await_activity_result(
&self,
context: &ActivityDispatchContext<'_>,
rx: &SyncReceiver,
) -> Result<String, String> {
match self.registry.is_registered(context.worker_id) {
Ok(true) => {}
Ok(false) => {
if let Ok(result) = rx.try_recv() {
return self.deliver_result(context, result);
}
self.cleanup_activity(
context.worker_id,
context.workflow_id,
context.activity_id,
&context.completion_token,
);
let reason = self.pending.classify_worker_loss(
context.workflow_id,
context.activity_id,
context.worker_id,
);
log_worker_error(
"WorkerLost",
&self.namespace,
context.activity_type,
context.workflow_id,
context.activity_id,
Some(context.worker_id),
&reason,
);
return Err(reason);
}
Err(error) => {
self.cleanup_activity(
context.worker_id,
context.workflow_id,
context.activity_id,
&context.completion_token,
);
let reason = format!("worker registry inspection failed: {error}");
log_worker_error(
"WorkerRegistry",
&self.namespace,
context.activity_type,
context.workflow_id,
context.activity_id,
Some(context.worker_id),
&reason,
);
return Err(reason);
}
}
if let Ok(result) = rx.recv() {
return self.deliver_result(context, result);
}
self.cleanup_activity(
context.worker_id,
context.workflow_id,
context.activity_id,
&context.completion_token,
);
let reason = "activity response channel dropped".to_owned();
log_worker_error(
"WorkerChannelClosed",
&self.namespace,
context.activity_type,
context.workflow_id,
context.activity_id,
Some(context.worker_id),
&reason,
);
Err(reason)
}
fn deliver_result(
&self,
context: &ActivityDispatchContext<'_>,
result: Result<String, String>,
) -> Result<String, String> {
self.pending
.pending
.remove(&(context.workflow_id.clone(), context.activity_id.clone()));
if let Err(reason) = &result
&& aion::is_parked_reason(reason)
{
tracing::info!(
operation = "activity_dispatch",
namespace = %self.namespace,
workflow_id = %context.workflow_id,
activity_id = %context.activity_id,
activity_type = context.activity_type,
worker_id = ?context.worker_id,
"activity parked for restart recovery"
);
return result;
}
log_activity_completion(context, result.is_ok());
result.inspect_err(|reason| {
log_worker_error(
"ActivityFailed",
&self.namespace,
context.activity_type,
context.workflow_id,
context.activity_id,
Some(context.worker_id),
reason,
);
})
}
}
impl ActivityDispatcher for WorkerActivityDispatcher {
fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
match tokio::runtime::Handle::try_current() {
Ok(handle) => match handle.runtime_flavor() {
tokio::runtime::RuntimeFlavor::MultiThread => {
tokio::task::block_in_place(|| self.dispatch_blocking(request))
}
flavor => Err(format!(
"activity dispatch blocks the calling thread until the worker responds; \
a {flavor:?} tokio runtime cannot host that wait because the worker \
stream forwarder shares its only executor thread and the task could \
never be delivered — run the engine on a multi-thread tokio runtime"
)),
},
Err(_) => self.dispatch_blocking(request),
}
}
}
impl WorkerActivityDispatcher {
fn dispatch_blocking(&self, request: ActivityDispatch) -> Result<String, String> {
let ActivityDispatch {
namespace,
task_queue,
node,
workflow_id,
run_id,
activity_id,
name,
input,
config: _,
attempt,
labels,
advisory: _,
} = request;
let started_at = Instant::now();
self.ensure_accepting(&namespace, &name, &workflow_id, &activity_id, None)?;
let address = ServiceAddress {
namespace: namespace.clone(),
task_queue: task_queue.clone(),
activity_type: name.clone(),
node: node.clone(),
};
let worker = self.select_worker_or_wait(&address, &workflow_id, &activity_id)?;
let worker_id = worker.id();
let span = info_span!(
"activity_dispatch",
operation = "activity_dispatch",
namespace = %namespace,
task_queue = %task_queue,
node = node.as_deref(),
workflow_id = %workflow_id,
activity_id = %activity_id,
activity_type = %name,
worker_id = ?worker_id,
);
let _span_guard = span.enter();
self.ensure_accepting(
&namespace,
&name,
&workflow_id,
&activity_id,
Some(worker_id),
)?;
let (completion_token, rx) = self
.pending
.insert(workflow_id.clone(), activity_id.clone())
.map_err(|error| error.to_string())?;
let task = activity_task(
&name,
&input,
(&workflow_id, &run_id, &activity_id),
attempt,
labels,
&completion_token,
);
if let Err(error) = self.track_worker_task(
worker_id,
&name,
&workflow_id,
&activity_id,
attempt,
completion_token.clone(),
) {
self.cleanup_activity(worker_id, &workflow_id, &activity_id, &completion_token);
return Err(error);
}
self.send_activity_task(
&worker,
task,
&address,
&workflow_id,
&activity_id,
&completion_token,
)?;
let context = ActivityDispatchContext {
namespace: &namespace,
activity_type: &name,
worker_id,
workflow_id: &workflow_id,
activity_id: &activity_id,
completion_token,
started_at,
};
self.await_activity_result(&context, &rx)
}
}
#[cfg(feature = "liminal-transport")]
fn route_liminal_reply(
pending: &PendingActivities,
heartbeat_tracker: &HeartbeatTracker,
drain_state: &DrainState,
awaiter: &liminal_server::server::connection::PushReplyAwaiter,
execution: (WorkerId, &WorkflowId, &ActivityId, &CompletionToken),
) {
let (worker_id, workflow_id, activity_id, current_token) = execution;
let waited = super::liminal_transport::receive_bridge_reply(awaiter, || {
heartbeat_tracker
.is_tracked(worker_id, workflow_id, activity_id)
.unwrap_or(false)
});
let (run_id, submitted_token, outcome, synthesized) = match waited {
Ok(Some(response)) => {
let submitted_token = match CompletionToken::from_wire(
workflow_id,
activity_id,
response.completion_token,
) {
Ok(token) => token,
Err(error) => {
tracing::warn!(
worker_id = ?worker_id,
workflow_id = %workflow_id,
activity_id = %activity_id,
%error,
"liminal activity completion omitted its generation proof"
);
return;
}
};
(response.run_id, submitted_token, response.outcome, false)
}
Ok(None) => {
tracing::debug!(
worker_id = ?worker_id,
workflow_id = %workflow_id,
activity_id = %activity_id,
"liminal dispatch resolved by another path; abandoning reply wait"
);
return;
}
Err(error) if error.is_worker_connection_lost() => (
None,
current_token.clone(),
Err(pending.classify_worker_loss(workflow_id, activity_id, worker_id)),
true,
),
Err(error) => (
None,
current_token.clone(),
Err(format!("retryable:worker liminal reply failed: {error}")),
true,
),
};
if synthesized {
let was_tracked =
complete_liminal_tracking(heartbeat_tracker, worker_id, workflow_id, activity_id);
if !was_tracked {
tracing::debug!(
worker_id = ?worker_id,
workflow_id = %workflow_id,
activity_id = %activity_id,
"liminal dispatch already resolved; dropping synthesized lost-worker failure"
);
return;
}
}
if let Err(error) = pending.complete_fenced(
workflow_id,
activity_id,
run_id.as_ref(),
&submitted_token,
outcome,
) {
tracing::warn!(
worker_id = ?worker_id,
workflow_id = %workflow_id,
activity_id = %activity_id,
%error,
"liminal activity completion handoff rejected"
);
return;
}
if !synthesized
&& let Err(error) = heartbeat_tracker.complete_task(worker_id, workflow_id, activity_id)
{
tracing::error!(
worker_id = ?worker_id,
workflow_id = %workflow_id,
activity_id = %activity_id,
%error,
"failed to clear in-flight tracking for completed liminal activity"
);
}
drain_state.notify_activity_drained();
}
#[cfg(feature = "liminal-transport")]
fn complete_liminal_tracking(
heartbeat_tracker: &HeartbeatTracker,
worker_id: WorkerId,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
) -> bool {
heartbeat_tracker
.complete_task(worker_id, workflow_id, activity_id)
.unwrap_or_else(|error| {
tracing::error!(
worker_id = ?worker_id,
workflow_id = %workflow_id,
activity_id = %activity_id,
%error,
"failed to clear in-flight tracking for completed liminal activity"
);
true
})
}
struct ActivityDispatchContext<'a> {
namespace: &'a str,
activity_type: &'a str,
worker_id: WorkerId,
workflow_id: &'a WorkflowId,
activity_id: &'a ActivityId,
completion_token: CompletionToken,
started_at: Instant,
}
fn activity_task(
activity_type: &str,
input: &str,
execution: (&WorkflowId, &RunId, &ActivityId),
attempt: u32,
labels: BTreeMap<String, String>,
completion_token: &CompletionToken,
) -> ProtoActivityTask {
let (workflow_id, run_id, activity_id) = execution;
ProtoActivityTask {
workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
activity_id: Some(ProtoActivityId::from(activity_id.clone())),
activity_type: activity_type.to_owned(),
input: Some(ProtoPayload {
content_type: String::from("application/json"),
bytes: input.as_bytes().to_vec(),
}),
attempt,
labels: labels.into_iter().collect(),
run_id: Some(run_id.clone().into()),
completion_token: completion_token.as_str().to_owned(),
idempotency_key: idempotency_key(workflow_id, run_id, activity_id),
}
}
fn log_activity_completion(context: &ActivityDispatchContext<'_>, succeeded: bool) {
let duration_ms = duration_ms(context.started_at.elapsed());
tracing::info!(
operation = "activity_complete",
namespace = context.namespace,
workflow_id = %context.workflow_id,
activity_id = %context.activity_id,
activity_type = context.activity_type,
worker_id = ?context.worker_id,
duration_ms,
outcome = if succeeded { "succeeded" } else { "failed" },
"activity completed"
);
}
fn duration_ms(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
fn log_worker_error(
error_type: &'static str,
namespace: &str,
activity_type: &str,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
worker_id: Option<super::registry::WorkerId>,
reason: &str,
) {
tracing::error!(
operation = "activity_dispatch",
namespace,
workflow_id = %workflow_id,
activity_id = %activity_id,
activity_type,
worker_id = ?worker_id,
error_type,
reason,
"worker interaction failed"
);
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use aion_core::{ActivityError, ActivityErrorKind, ContentType, Payload};
use super::*;
fn activity_id(pos: u64) -> ActivityId {
ActivityId::from_sequence_position(pos)
}
#[test]
fn pending_insert_and_complete_delivers_result() -> Result<(), ServerError> {
let pending = PendingActivities::default();
let workflow_id = WorkflowId::new_v4();
let id = activity_id(1);
let rx = pending.insert(workflow_id.clone(), id.clone())?.1;
assert!(pending.complete(&workflow_id, &id, None, Ok("done".to_owned())));
assert_eq!(
rx.recv_timeout(Duration::from_millis(50)),
Ok(Ok("done".to_owned()))
);
Ok(())
}
#[test]
fn pending_complete_unknown_returns_false() {
let pending = PendingActivities::default();
assert!(!pending.complete(
&WorkflowId::new_v4(),
&activity_id(99),
None,
Ok("orphan".to_owned())
));
}
#[derive(Default)]
struct RecordingOutboxCallback {
completions: Mutex<Vec<(WorkflowId, ActivityId, String)>>,
failures: Mutex<Vec<(WorkflowId, ActivityId, String)>>,
live: bool,
}
impl OutboxDeliveryCallback for RecordingOutboxCallback {
fn deliver_completion(
&self,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
run_id: Option<&RunId>,
result: String,
) -> Result<bool, ServerError> {
let _ = run_id;
self.completions
.lock()
.map_err(|_| ServerError::lock_poisoned("recording outbox callback"))?
.push((workflow_id.clone(), activity_id.clone(), result));
Ok(self.live)
}
fn deliver_failure(
&self,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
run_id: Option<&RunId>,
reason: String,
) -> Result<bool, ServerError> {
let _ = run_id;
self.failures
.lock()
.map_err(|_| ServerError::lock_poisoned("recording outbox callback"))?
.push((workflow_id.clone(), activity_id.clone(), reason));
Ok(self.live)
}
}
#[test]
fn unmatched_completion_routes_to_outbox_callback_when_installed() -> Result<(), ServerError> {
let pending = PendingActivities::default();
let callback = Arc::new(RecordingOutboxCallback {
live: true,
..RecordingOutboxCallback::default()
});
pending.clone().set_outbox_delivery(callback.clone());
let workflow_id = WorkflowId::new_v4();
let id = activity_id(7);
assert!(pending.complete(&workflow_id, &id, None, Ok("done".to_owned())));
let completions = callback
.completions
.lock()
.map_err(|_| ServerError::lock_poisoned("recording outbox callback"))?;
assert_eq!(completions.len(), 1);
assert_eq!(completions[0].0, workflow_id);
assert_eq!(completions[0].1, id);
assert_eq!(completions[0].2, "done");
Ok(())
}
#[test]
fn unmatched_failure_routes_to_outbox_callback_and_not_live_reports_false()
-> Result<(), ServerError> {
let pending = PendingActivities::default();
let callback = Arc::new(RecordingOutboxCallback::default());
pending.set_outbox_delivery(callback.clone());
let workflow_id = WorkflowId::new_v4();
let id = activity_id(8);
assert!(!pending.complete(&workflow_id, &id, None, Err("retryable:boom".to_owned())));
let failures = callback
.failures
.lock()
.map_err(|_| ServerError::lock_poisoned("recording outbox callback"))?;
assert_eq!(failures.len(), 1);
assert_eq!(failures[0].2, "retryable:boom");
Ok(())
}
#[test]
fn unmatched_completion_is_silent_drop_when_no_callback_installed() {
let pending = PendingActivities::default();
assert!(!pending.complete(
&WorkflowId::new_v4(),
&activity_id(9),
None,
Ok("x".to_owned())
));
}
#[test]
fn matched_completion_never_reaches_outbox_callback() -> Result<(), ServerError> {
let pending = PendingActivities::default();
let callback = Arc::new(RecordingOutboxCallback {
live: true,
..RecordingOutboxCallback::default()
});
pending.set_outbox_delivery(callback.clone());
let workflow_id = WorkflowId::new_v4();
let id = activity_id(10);
let rx = pending.insert(workflow_id.clone(), id.clone())?.1;
assert!(pending.complete(&workflow_id, &id, None, Ok("matched".to_owned())));
assert_eq!(
rx.recv_timeout(Duration::from_millis(50)),
Ok(Ok("matched".to_owned()))
);
assert!(
callback
.completions
.lock()
.map_err(|_| ServerError::lock_poisoned("recording outbox callback"))?
.is_empty(),
"a matched completion must deliver to its waiter, not the outbox callback"
);
Ok(())
}
#[test]
fn park_activity_resolves_matched_waiter_with_the_parked_sentinel() -> Result<(), ServerError> {
let pending = PendingActivities::default();
let workflow_id = WorkflowId::new_v4();
let id = activity_id(11);
let rx = pending.insert(workflow_id.clone(), id.clone())?.1;
pending.park_activity(&workflow_id, &id)?;
let result = rx
.recv_timeout(Duration::from_millis(50))
.map_err(|e| ServerError::worker_dispatch("", "", format!("channel: {e}")))?;
assert_eq!(result, Err(aion::PARKED_ACTIVITY_REASON.to_owned()));
Ok(())
}
#[test]
fn unmatched_park_is_a_noop_and_never_reaches_the_outbox_callback() -> Result<(), ServerError> {
let pending = PendingActivities::default();
let callback = Arc::new(RecordingOutboxCallback {
live: true,
..RecordingOutboxCallback::default()
});
pending.set_outbox_delivery(callback.clone());
pending.park_activity(&WorkflowId::new_v4(), &activity_id(12))?;
assert!(
callback
.failures
.lock()
.map_err(|_| ServerError::lock_poisoned("recording outbox callback"))?
.is_empty(),
"a park must never be delivered as an outbox failure"
);
assert!(
callback
.completions
.lock()
.map_err(|_| ServerError::lock_poisoned("recording outbox callback"))?
.is_empty(),
"a park must never be delivered as an outbox completion"
);
Ok(())
}
#[test]
fn completion_sink_routes_success() -> Result<(), ServerError> {
let pending = PendingActivities::default();
let workflow_id = WorkflowId::new_v4();
let id = activity_id(2);
let (completion_token, rx) = pending.insert(workflow_id.clone(), id.clone())?;
let payload = Payload::new(ContentType::Json, br#"{"greeting":"hi"}"#.to_vec());
pending.complete_activity(ActivityCompletion {
workflow_id,
activity_id: id,
run_id: None,
completion_token,
outcome: ActivityCompletionOutcome::Succeeded(payload),
})?;
let result = rx
.recv_timeout(Duration::from_millis(50))
.map_err(|e| ServerError::worker_dispatch("", "", format!("channel: {e}")))?;
assert_eq!(result, Ok(r#"{"greeting":"hi"}"#.to_owned()));
Ok(())
}
#[test]
fn malformed_payload_does_not_consume_the_current_generation() -> Result<(), ServerError> {
let pending = PendingActivities::default();
let workflow_id = WorkflowId::new_v4();
let id = activity_id(12);
let (completion_token, rx) = pending.insert(workflow_id.clone(), id.clone())?;
let malformed = pending.complete_activity(ActivityCompletion {
workflow_id: workflow_id.clone(),
activity_id: id.clone(),
run_id: None,
completion_token: completion_token.clone(),
outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
ContentType::Json,
vec![0xff],
)),
});
assert!(matches!(malformed, Err(ServerError::WorkerDispatch { .. })));
assert!(
rx.try_recv().is_err(),
"an invalid result must leave the waiter unresolved"
);
pending.complete_activity(ActivityCompletion {
workflow_id,
activity_id: id,
run_id: None,
completion_token,
outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
ContentType::Json,
br#""valid""#.to_vec(),
)),
})?;
let result = rx
.recv_timeout(Duration::from_millis(50))
.map_err(|error| ServerError::worker_dispatch("", "", format!("channel: {error}")))?;
assert_eq!(result, Ok(r#""valid""#.to_owned()));
Ok(())
}
#[test]
fn completion_sink_routes_retryable_error() -> Result<(), ServerError> {
let pending = PendingActivities::default();
let workflow_id = WorkflowId::new_v4();
let id = activity_id(3);
let (completion_token, rx) = pending.insert(workflow_id.clone(), id.clone())?;
pending.complete_activity(ActivityCompletion {
workflow_id,
activity_id: id,
run_id: None,
completion_token,
outcome: ActivityCompletionOutcome::Failed(ActivityError {
kind: ActivityErrorKind::Retryable,
message: "temporary".to_owned(),
details: None,
}),
})?;
let result = rx
.recv_timeout(Duration::from_millis(50))
.map_err(|e| ServerError::worker_dispatch("", "", format!("channel: {e}")))?;
assert_eq!(result, Err("retryable:temporary".to_owned()));
Ok(())
}
#[test]
fn stale_result_for_other_workflow_does_not_complete_pending_dispatch()
-> Result<(), ServerError> {
let pending = PendingActivities::default();
let post_restart_workflow = WorkflowId::new_v4();
let pre_restart_workflow = WorkflowId::new_v4();
let id = activity_id(1);
let (completion_token, rx) = pending.insert(post_restart_workflow.clone(), id.clone())?;
let rejected = pending.complete_activity(ActivityCompletion {
workflow_id: pre_restart_workflow,
activity_id: id.clone(),
run_id: None,
completion_token: CompletionToken::for_test(),
outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
ContentType::Json,
br#""stale""#.to_vec(),
)),
});
assert!(matches!(
rejected,
Err(ServerError::ActivityCompletionRejected { .. })
));
assert!(
rx.try_recv().is_err(),
"stale result for a different workflow must not complete this dispatch"
);
pending.complete_activity(ActivityCompletion {
workflow_id: post_restart_workflow,
activity_id: id,
run_id: None,
completion_token,
outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
ContentType::Json,
br#""fresh""#.to_vec(),
)),
})?;
let result = rx
.recv_timeout(Duration::from_millis(50))
.map_err(|e| ServerError::worker_dispatch("", "", format!("channel: {e}")))?;
assert_eq!(result, Ok(r#""fresh""#.to_owned()));
Ok(())
}
fn test_tracker() -> HeartbeatTracker {
HeartbeatTracker::new(Duration::from_secs(5))
}
fn greet_request() -> ActivityDispatch {
ActivityDispatch {
namespace: "default".to_owned(),
task_queue: "default".to_owned(),
node: None,
workflow_id: WorkflowId::new_v4(),
run_id: RunId::new_v4(),
activity_id: ActivityId::from_sequence_position(0),
name: "greet".to_owned(),
input: "{}".to_owned(),
config: "{}".to_owned(),
attempt: 1,
labels: std::collections::BTreeMap::new(),
advisory: false,
}
}
#[test]
fn dispatcher_fails_immediately_when_draining_without_workers() {
let registry = ConnectedWorkerRegistry::default();
let drain = DrainState::default();
let dispatcher = WorkerActivityDispatcher::new(registry, "default", test_tracker())
.with_drain_state(drain.clone());
let _ = drain.begin();
let result = dispatcher.dispatch(greet_request());
assert!(result.is_err());
let err = result.err().unwrap_or_default();
assert!(
err.contains("drain"),
"expected drain rejection, got: {err}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dispatch_inside_runtime_task_delivers_promptly_and_round_trips()
-> Result<(), Box<dyn std::error::Error>> {
let registry = ConnectedWorkerRegistry::default();
let pending = PendingActivities::default();
let (worker_tx, mut worker_rx) = tokio::sync::mpsc::channel(32);
let activity_types = [String::from("greet")];
let registration = registry.register("default", activity_types.iter(), worker_tx)?;
let sink = pending.clone();
let echo_worker = tokio::spawn(async move {
let Some(WorkerMessage::ActivityTask(task)) = worker_rx.recv().await else {
return Err("expected an activity task on the worker channel".to_owned());
};
let workflow_id = task
.workflow_id
.ok_or("task missing workflow id")
.and_then(|id| WorkflowId::try_from(id).map_err(|_| "bad workflow id"))?;
let activity_id = task
.activity_id
.map(ActivityId::from)
.ok_or("task missing activity id")?;
let completion_token =
CompletionToken::from_wire(&workflow_id, &activity_id, task.completion_token)
.map_err(|error| error.to_string())?;
sink.complete_activity(ActivityCompletion {
workflow_id,
activity_id,
run_id: None,
completion_token,
outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
ContentType::Json,
br#"{"greeting":"hello"}"#.to_vec(),
)),
})
.map_err(|error| error.to_string())
});
let dispatcher = Arc::new(
WorkerActivityDispatcher::new(registry, "default", test_tracker())
.with_pending(pending),
);
let started = Instant::now();
let dispatch_task = tokio::spawn(futures::future::lazy(move |_| {
dispatcher.dispatch(greet_request())
}));
let result = dispatch_task.await.map_err(|error| error.to_string())?;
let elapsed = started.elapsed();
assert_eq!(result, Ok(r#"{"greeting":"hello"}"#.to_owned()));
assert!(
elapsed < Duration::from_secs(5),
"dispatch round trip took {elapsed:?}; task delivery must not \
depend on the blocked dispatch thread"
);
echo_worker.await.map_err(|error| error.to_string())??;
registration.deregister()?;
Ok(())
}
#[tokio::test]
async fn dispatch_on_current_thread_runtime_fails_fast()
-> Result<(), Box<dyn std::error::Error>> {
let registry = ConnectedWorkerRegistry::default();
let (worker_tx, _worker_rx) = tokio::sync::mpsc::channel(32);
let activity_types = [String::from("greet")];
let registration = registry.register("default", activity_types.iter(), worker_tx)?;
let dispatcher = WorkerActivityDispatcher::new(registry, "default", test_tracker());
let started = Instant::now();
let result = dispatcher.dispatch(greet_request());
let elapsed = started.elapsed();
let err = result.err().ok_or("expected dispatch to fail")?;
assert!(
err.contains("multi-thread tokio runtime"),
"unexpected error: {err}"
);
assert!(
elapsed < Duration::from_secs(5),
"fail-fast path took {elapsed:?}"
);
registration.deregister()?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dispatch_pinned_to_node_reaches_only_that_node()
-> Result<(), Box<dyn std::error::Error>> {
let registry = ConnectedWorkerRegistry::default();
let pending = PendingActivities::default();
let activity_types = [String::from("greet")];
let (n1_tx, mut n1_rx) = tokio::sync::mpsc::channel(32);
let (n2_tx, mut n2_rx) = tokio::sync::mpsc::channel(32);
let on_n2 = registry.register_namespaces(
[String::from("default")],
"default",
Some(String::from("n2")),
activity_types.iter(),
n2_tx,
)?;
let on_n1 = registry.register_namespaces(
[String::from("default")],
"default",
Some(String::from("n1")),
activity_types.iter(),
n1_tx,
)?;
let sink = pending.clone();
let echo_n1 = tokio::spawn(async move {
let Some(WorkerMessage::ActivityTask(task)) = n1_rx.recv().await else {
return Err("expected an activity task on the n1 worker channel".to_owned());
};
let workflow_id = task
.workflow_id
.ok_or("task missing workflow id")
.and_then(|id| WorkflowId::try_from(id).map_err(|_| "bad workflow id"))?;
let activity_id = task
.activity_id
.map(ActivityId::from)
.ok_or("task missing activity id")?;
let completion_token =
CompletionToken::from_wire(&workflow_id, &activity_id, task.completion_token)
.map_err(|error| error.to_string())?;
sink.complete_activity(ActivityCompletion {
workflow_id,
activity_id,
run_id: None,
completion_token,
outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
ContentType::Json,
br#"{"greeting":"hello"}"#.to_vec(),
)),
})
.map_err(|error| error.to_string())
});
let dispatcher = Arc::new(
WorkerActivityDispatcher::new(registry.clone(), "default", test_tracker())
.with_pending(pending),
);
let pinned = ActivityDispatch {
node: Some(String::from("n1")),
..greet_request()
};
let started = Instant::now();
let result = tokio::spawn(futures::future::lazy(move |_| dispatcher.dispatch(pinned)))
.await
.map_err(|error| error.to_string())?;
let elapsed = started.elapsed();
assert_eq!(result, Ok(r#"{"greeting":"hello"}"#.to_owned()));
assert!(
elapsed < Duration::from_secs(5),
"pinned dispatch round trip took {elapsed:?}; the task must route to n1"
);
echo_n1.await.map_err(|error| error.to_string())??;
assert!(
n2_rx.try_recv().is_err(),
"node=Some(\"n1\") dispatch must not reach the n2 worker"
);
on_n1.deregister()?;
on_n2.deregister()?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unpinned_dispatch_reaches_a_pooled_worker_regardless_of_node()
-> Result<(), Box<dyn std::error::Error>> {
let registry = ConnectedWorkerRegistry::default();
let pending = PendingActivities::default();
let activity_types = [String::from("greet")];
let (n1_tx, mut n1_rx) = tokio::sync::mpsc::channel(32);
let on_n1 = registry.register_namespaces(
[String::from("default")],
"default",
Some(String::from("n1")),
activity_types.iter(),
n1_tx,
)?;
let sink = pending.clone();
let echo = tokio::spawn(async move {
let Some(WorkerMessage::ActivityTask(task)) = n1_rx.recv().await else {
return Err("expected an activity task on the worker channel".to_owned());
};
let workflow_id = task
.workflow_id
.ok_or("task missing workflow id")
.and_then(|id| WorkflowId::try_from(id).map_err(|_| "bad workflow id"))?;
let activity_id = task
.activity_id
.map(ActivityId::from)
.ok_or("task missing activity id")?;
let completion_token =
CompletionToken::from_wire(&workflow_id, &activity_id, task.completion_token)
.map_err(|error| error.to_string())?;
sink.complete_activity(ActivityCompletion {
workflow_id,
activity_id,
run_id: None,
completion_token,
outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
ContentType::Json,
br#"{"greeting":"hello"}"#.to_vec(),
)),
})
.map_err(|error| error.to_string())
});
let dispatcher = Arc::new(
WorkerActivityDispatcher::new(registry.clone(), "default", test_tracker())
.with_pending(pending),
);
let result = tokio::spawn(futures::future::lazy(move |_| {
dispatcher.dispatch(greet_request())
}))
.await
.map_err(|error| error.to_string())?;
assert_eq!(result, Ok(r#"{"greeting":"hello"}"#.to_owned()));
echo.await.map_err(|error| error.to_string())??;
on_n1.deregister()?;
Ok(())
}
}