use aion_proto::{
ProtoCancelRequest, ProtoCancelResponse, ProtoPauseRequest, ProtoPauseResponse,
ProtoQueryRequest, ProtoQueryResponse, ProtoReopenRequest, ProtoReopenResponse,
ProtoResumeRequest, ProtoResumeResponse, ProtoSignalRequest, ProtoSignalResponse,
ProtoStartWorkflowRequest, ProtoStartWorkflowResponse, WireError, WireErrorCode,
proto_query_response,
};
use tracing::{Instrument, info_span};
use super::error::{
cancel_terminal_error, log_server_error, map_start_error, map_workflow_operation_error,
signal_terminal_error,
};
use super::payload::{optional_payload, required_payload, required_workflow_id};
use super::runs::{resolve_run_id, terminal_status};
use crate::{
CallerIdentity, NamespaceGuard, NamespaceMinter, NamespaceOperation, ServerError, ServerState,
WorkflowTarget,
};
pub async fn start(
guard: &NamespaceGuard,
caller: &CallerIdentity,
request: ProtoStartWorkflowRequest,
) -> Result<ProtoStartWorkflowResponse, WireError> {
start_with_placement(guard, caller, request, None, None).await
}
pub async fn start_with_placement(
guard: &NamespaceGuard,
caller: &CallerIdentity,
request: ProtoStartWorkflowRequest,
placement: Option<aion_core::WorkflowId>,
minter: Option<&NamespaceMinter>,
) -> Result<ProtoStartWorkflowResponse, WireError> {
let scoped = guard
.scope(caller, &NamespaceOperation::start(&request))
.await
.map_err(|error| error.to_wire_error())?;
let namespace = scoped.namespace().to_owned();
if let Some(minter) = minter {
minter
.mint_or_gate(
std::slice::from_ref(&namespace),
aion_store::NamespaceOrigin::StartMint,
)
.await
.map_err(|error| error.to_wire_error())?;
}
let input = required_payload(request.input.clone())?;
let task_queue = request
.task_queue
.as_deref()
.map(str::trim)
.filter(|queue| !queue.is_empty());
let display_name = match request.display_name.as_deref().map(str::trim) {
Some("") => {
return Err(WireError::invalid_input(
"display_name must not be blank; omit it to start the run unnamed",
));
}
other => other,
};
let span = info_span!(
"engine_operation",
operation = "start",
namespace = %namespace,
workflow_id = tracing::field::Empty,
workflow_type = %request.workflow_type,
);
let search_attributes = start_search_attributes(&namespace, task_queue, display_name);
let handle = async {
scoped
.engine()
.map_err(|error| log_server_error("start", Some(&namespace), None, &error))?
.start_workflow_with_id(
&request.workflow_type,
input,
search_attributes,
namespace.clone(),
placement,
None,
)
.await
.map_err(|error| map_start_error(error, &request.workflow_type))
}
.instrument(span.clone())
.await?;
span.record("workflow_id", tracing::field::display(handle.workflow_id()));
Ok(ProtoStartWorkflowResponse {
workflow_id: Some(handle.workflow_id().clone().into()),
run_id: Some(handle.run_id().clone().into()),
})
}
pub(crate) fn start_search_attributes(
namespace: &str,
task_queue: Option<&str>,
display_name: Option<&str>,
) -> std::collections::HashMap<String, aion_core::SearchAttributeValue> {
let mut attributes = std::collections::HashMap::from([(
crate::namespace::NAMESPACE_ATTRIBUTE.to_owned(),
aion_core::SearchAttributeValue::String(namespace.to_owned()),
)]);
if let Some(task_queue) = task_queue {
attributes.insert(
crate::namespace::TASK_QUEUE_ATTRIBUTE.to_owned(),
aion_core::SearchAttributeValue::String(task_queue.to_owned()),
);
}
if let Some(display_name) = display_name {
attributes.insert(
crate::namespace::DISPLAY_NAME_ATTRIBUTE.to_owned(),
aion_core::SearchAttributeValue::String(display_name.to_owned()),
);
}
attributes
}
pub async fn signal(
guard: &NamespaceGuard,
caller: &CallerIdentity,
request: ProtoSignalRequest,
) -> Result<ProtoSignalResponse, WireError> {
let workflow_id = required_workflow_id(request.workflow_id.clone())?;
let target = WorkflowTarget::workflow(&workflow_id);
let scoped = guard
.scope(caller, &NamespaceOperation::signal(&request, target))
.await
.map_err(|error| error.to_wire_error())?;
let namespace = scoped.namespace().to_owned();
let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
let payload = required_payload(request.payload.clone())?;
if let Some(status) = terminal_status(engine.as_ref(), &workflow_id).await? {
return Err(signal_terminal_error(&workflow_id, status));
}
let signal_name = request.signal_name.clone();
let span = info_span!(
"engine_operation",
operation = "signal",
namespace = %namespace,
workflow_id = %workflow_id,
signal_name = %signal_name,
);
async {
engine
.signal(&workflow_id, &run_id, signal_name, payload)
.await
.map_err(|error| map_workflow_operation_error(error, &workflow_id))
}
.instrument(span)
.await?;
Ok(ProtoSignalResponse {})
}
pub async fn query(
guard: &NamespaceGuard,
caller: &CallerIdentity,
request: ProtoQueryRequest,
) -> Result<ProtoQueryResponse, WireError> {
let workflow_id = required_workflow_id(request.workflow_id.clone())?;
let target = WorkflowTarget::workflow(&workflow_id);
let scoped = guard
.scope(caller, &NamespaceOperation::query(&request, target))
.await
.map_err(|error| error.to_wire_error())?;
let namespace = scoped.namespace().to_owned();
let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
let query_name = request.query_name.clone();
let arguments = optional_payload(request.arguments.clone())?;
let span = info_span!(
"engine_operation",
operation = "query",
namespace = %namespace,
workflow_id = %workflow_id,
query_name = %query_name,
);
let outcome = async {
engine
.query(&workflow_id, &run_id, query_name, arguments)
.await
}
.instrument(span)
.await;
match outcome {
Ok(result) => Ok(ProtoQueryResponse {
outcome: Some(proto_query_response::Outcome::Result(result.into())),
}),
Err(error @ aion::EngineError::Query(_)) => Ok(ProtoQueryResponse {
outcome: Some(proto_query_response::Outcome::Error(
ServerError::from(error).to_wire_error().into(),
)),
}),
Err(error) => Err(map_workflow_operation_error(error, &workflow_id)),
}
}
pub async fn cancel(
state: &ServerState,
guard: &NamespaceGuard,
caller: &CallerIdentity,
request: ProtoCancelRequest,
) -> Result<ProtoCancelResponse, WireError> {
let workflow_id = required_workflow_id(request.workflow_id.clone())?;
let target = WorkflowTarget::workflow(&workflow_id);
let scoped = guard
.scope(caller, &NamespaceOperation::cancel(&request, target))
.await
.map_err(|error| error.to_wire_error())?;
let namespace = scoped.namespace().to_owned();
let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
if let Some(status) = terminal_status(engine.as_ref(), &workflow_id).await? {
return Err(cancel_terminal_error(&workflow_id, status));
}
let span = info_span!(
"engine_operation",
operation = "cancel",
namespace = %namespace,
workflow_id = %workflow_id,
);
async {
engine
.cancel(&workflow_id, &run_id, request.reason)
.await
.map_err(|error| map_workflow_operation_error(error, &workflow_id))
}
.instrument(span)
.await?;
state
.cancel_in_flight_activities(&workflow_id)
.map_err(|error| WireError::new(WireErrorCode::Backend, error.to_string()))?;
Ok(ProtoCancelResponse {})
}
pub async fn reopen(
guard: &NamespaceGuard,
caller: &CallerIdentity,
request: ProtoReopenRequest,
) -> Result<ProtoReopenResponse, WireError> {
let workflow_id = required_workflow_id(request.workflow_id.clone())?;
let target = WorkflowTarget::workflow(&workflow_id);
let scoped = guard
.scope(caller, &NamespaceOperation::reopen(&request, target))
.await
.map_err(|error| error.to_wire_error())?;
let namespace = scoped.namespace().to_owned();
let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
let span = info_span!(
"engine_operation",
operation = "reopen",
namespace = %namespace,
workflow_id = %workflow_id,
);
let handle = async {
engine
.reopen_workflow(&workflow_id, &run_id)
.await
.map_err(|error| map_workflow_operation_error(error, &workflow_id))
}
.instrument(span)
.await?;
Ok(ProtoReopenResponse {
run_id: Some(handle.run_id().clone().into()),
status: aion_proto::ProtoWorkflowStatus::from(handle.cached_status()) as i32,
})
}
pub async fn pause(
guard: &NamespaceGuard,
caller: &CallerIdentity,
request: ProtoPauseRequest,
) -> Result<ProtoPauseResponse, WireError> {
let workflow_id = required_workflow_id(request.workflow_id.clone())?;
let target = WorkflowTarget::workflow(&workflow_id);
let scoped = guard
.scope(
caller,
&NamespaceOperation::pause_workflow(&request, target),
)
.await
.map_err(|error| error.to_wire_error())?;
let namespace = scoped.namespace().to_owned();
let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
let reason = if request.reason.is_empty() {
None
} else {
Some(request.reason.clone())
};
let span = info_span!(
"engine_operation",
operation = "pause",
namespace = %namespace,
workflow_id = %workflow_id,
);
let handle = async {
engine
.pause_workflow(&workflow_id, &run_id, reason, None)
.await
.map_err(|error| map_workflow_operation_error(error, &workflow_id))
}
.instrument(span)
.await?;
Ok(ProtoPauseResponse {
run_id: Some(handle.run_id().clone().into()),
status: aion_proto::ProtoWorkflowStatus::Paused as i32,
})
}
pub async fn rename(
guard: &NamespaceGuard,
caller: &CallerIdentity,
request: aion_proto::ProtoRenameRequest,
) -> Result<aion_proto::ProtoRenameResponse, WireError> {
let workflow_id = required_workflow_id(request.workflow_id.clone())?;
let display_name = request.display_name.trim();
if display_name.is_empty() {
return Err(WireError::invalid_input(
"display_name must not be empty; a rename records a non-empty label",
));
}
let target = WorkflowTarget::workflow(&workflow_id);
let scoped = guard
.scope(
caller,
&NamespaceOperation::rename_workflow(&request, target),
)
.await
.map_err(|error| error.to_wire_error())?;
let namespace = scoped.namespace().to_owned();
let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
let span = info_span!(
"engine_operation",
operation = "rename",
namespace = %namespace,
workflow_id = %workflow_id,
);
let recorded = async {
engine
.rename_workflow(&workflow_id, &run_id, display_name)
.await
.map_err(|error| map_workflow_operation_error(error, &workflow_id))
}
.instrument(span)
.await?;
Ok(aion_proto::ProtoRenameResponse {
run_id: Some(run_id.into()),
display_name: recorded,
})
}
pub async fn resume(
guard: &NamespaceGuard,
caller: &CallerIdentity,
request: ProtoResumeRequest,
) -> Result<ProtoResumeResponse, WireError> {
let workflow_id = required_workflow_id(request.workflow_id.clone())?;
let target = WorkflowTarget::workflow(&workflow_id);
let scoped = guard
.scope(
caller,
&NamespaceOperation::resume_workflow(&request, target),
)
.await
.map_err(|error| error.to_wire_error())?;
let namespace = scoped.namespace().to_owned();
let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
let span = info_span!(
"engine_operation",
operation = "resume",
namespace = %namespace,
workflow_id = %workflow_id,
);
let handle = async {
engine
.resume_paused_workflow(&workflow_id, &run_id, None)
.await
.map_err(|error| map_workflow_operation_error(error, &workflow_id))
}
.instrument(span)
.await?;
Ok(ProtoResumeResponse {
run_id: Some(handle.run_id().clone().into()),
status: aion_proto::ProtoWorkflowStatus::Running as i32,
})
}
#[cfg(test)]
mod tests {
use aion_proto::{WireError, WireErrorCode};
use super::super::test_support::{
NAMESPACE, append_completed, append_failed, append_started, append_timed_out,
assert_workflow_not_found, cancel_request, context, denied_guard, proto_payload,
query_request, reopen_request, run_id, signal_request, workflow_id,
};
use super::*;
#[tokio::test]
async fn start_handler_scopes_then_invokes_engine_start()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
let request = ProtoStartWorkflowRequest {
namespace: NAMESPACE.to_owned(),
workflow_type: "missing-workflow".to_owned(),
input: Some(proto_payload()?),
routing_key: None,
task_queue: None,
display_name: None,
};
let error = start(&context.guard, &context.caller, request).await;
let error = error
.err()
.ok_or_else(|| WireError::backend("expected error"))?;
assert_eq!(error.code, WireErrorCode::NotFound);
assert_eq!(error.error_type.as_deref(), Some("WorkflowTypeNotFound"));
assert_eq!(
error.message,
"workflow type missing-workflow is not registered"
);
Ok(())
}
#[test]
fn start_records_namespace_only_when_no_task_queue_selected() {
use crate::namespace::{NAMESPACE_ATTRIBUTE, TASK_QUEUE_ATTRIBUTE};
let attributes = start_search_attributes("tenant-a", None, None);
assert_eq!(
attributes.get(NAMESPACE_ATTRIBUTE),
Some(&aion_core::SearchAttributeValue::String(
"tenant-a".to_owned()
))
);
assert!(!attributes.contains_key(TASK_QUEUE_ATTRIBUTE));
}
#[test]
fn start_records_selected_task_queue_durably_like_namespace() {
use crate::namespace::{NAMESPACE_ATTRIBUTE, TASK_QUEUE_ATTRIBUTE};
let attributes = start_search_attributes("tenant-a", Some("gpu"), None);
assert_eq!(
attributes.get(NAMESPACE_ATTRIBUTE),
Some(&aion_core::SearchAttributeValue::String(
"tenant-a".to_owned()
))
);
assert_eq!(
attributes.get(TASK_QUEUE_ATTRIBUTE),
Some(&aion_core::SearchAttributeValue::String("gpu".to_owned()))
);
}
#[test]
fn start_records_no_display_name_when_unnamed() {
use crate::namespace::{DISPLAY_NAME_ATTRIBUTE, NAMESPACE_ATTRIBUTE};
let attributes = start_search_attributes("tenant-a", None, None);
assert_eq!(
attributes.get(NAMESPACE_ATTRIBUTE),
Some(&aion_core::SearchAttributeValue::String(
"tenant-a".to_owned()
))
);
assert!(!attributes.contains_key(DISPLAY_NAME_ATTRIBUTE));
}
#[test]
fn start_records_display_name_durably_like_namespace() {
use crate::namespace::{DISPLAY_NAME_ATTRIBUTE, NAMESPACE_ATTRIBUTE};
let attributes = start_search_attributes("tenant-a", None, Some("Nightly settlement"));
assert_eq!(
attributes.get(NAMESPACE_ATTRIBUTE),
Some(&aion_core::SearchAttributeValue::String(
"tenant-a".to_owned()
))
);
assert_eq!(
attributes.get(DISPLAY_NAME_ATTRIBUTE),
Some(&aion_core::SearchAttributeValue::String(
"Nightly settlement".to_owned()
))
);
}
#[tokio::test]
async fn start_refuses_a_blank_display_name_rather_than_starting_unnamed()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
for blank in ["", " ", "\t\n "] {
let request = aion_proto::ProtoStartWorkflowRequest {
namespace: NAMESPACE.to_owned(),
workflow_type: "checkout".to_owned(),
input: Some(proto_payload()?),
routing_key: None,
task_queue: None,
display_name: Some(blank.to_owned()),
};
let error = start(&context.guard, &context.caller, request)
.await
.err()
.ok_or_else(|| WireError::backend("expected a blank-name refusal"))?;
assert_eq!(error.code, WireErrorCode::InvalidInput, "blank {blank:?}");
}
let request = aion_proto::ProtoStartWorkflowRequest {
namespace: NAMESPACE.to_owned(),
workflow_type: "checkout".to_owned(),
input: Some(proto_payload()?),
routing_key: None,
task_queue: None,
display_name: None,
};
let error = start(&context.guard, &context.caller, request)
.await
.err()
.ok_or_else(|| WireError::backend("expected an error"))?;
assert_ne!(
error.code,
WireErrorCode::InvalidInput,
"an absent name must not be refused as invalid input"
);
Ok(())
}
#[tokio::test]
async fn rename_handler_rejects_blank_display_name() -> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
let request = aion_proto::ProtoRenameRequest {
namespace: NAMESPACE.to_owned(),
workflow_id: Some(workflow_id().into()),
run_id: Some(run_id().into()),
display_name: " ".to_owned(),
};
let error = rename(&context.guard, &context.caller, request).await;
let error = error
.err()
.ok_or_else(|| WireError::backend("expected error"))?;
assert_eq!(error.code, WireErrorCode::InvalidInput);
Ok(())
}
#[tokio::test]
async fn rename_handler_scopes_then_reports_absent_workflow()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
let request = aion_proto::ProtoRenameRequest {
namespace: NAMESPACE.to_owned(),
workflow_id: Some(workflow_id().into()),
run_id: Some(run_id().into()),
display_name: "Nightly settlement".to_owned(),
};
let error = rename(&context.guard, &context.caller, request).await;
let error = error
.err()
.ok_or_else(|| WireError::backend("expected error"))?;
assert_eq!(error.code, WireErrorCode::NotFound);
assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
Ok(())
}
#[tokio::test]
async fn signal_handler_scopes_then_invokes_engine_signal()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
let error = signal(&context.guard, &context.caller, signal_request()?).await;
let error = error
.err()
.ok_or_else(|| WireError::backend("expected error"))?;
assert_eq!(error.code, WireErrorCode::NotFound);
assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
assert_eq!(
error.message,
format!("workflow {} not found", workflow_id())
);
Ok(())
}
#[tokio::test]
async fn query_handler_scopes_then_invokes_engine_query()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
let error = query(&context.guard, &context.caller, query_request()).await;
let error = error
.err()
.ok_or_else(|| WireError::backend("expected error"))?;
assert_eq!(error.code, WireErrorCode::NotFound);
assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
assert_eq!(
error.message,
format!("workflow {} not found", workflow_id())
);
Ok(())
}
#[tokio::test]
async fn query_handler_returns_not_running_outcome_for_terminal_workflow()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
append_completed(context.store.as_ref()).await?;
let mut request = query_request();
request.run_id = None;
let response = query(&context.guard, &context.caller, request).await?;
let Some(proto_query_response::Outcome::Error(error)) = response.outcome else {
return Err("expected a QueryResponse.error outcome".into());
};
let error = WireError::try_from(error)?;
assert_eq!(error.code, WireErrorCode::NotRunning);
assert_eq!(error.error_type.as_deref(), Some("QueryNotRunning"));
Ok(())
}
#[tokio::test]
async fn query_handler_keeps_non_resident_non_terminal_workflow_as_transport_not_found()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
append_started(context.store.as_ref()).await?;
let mut request = query_request();
request.run_id = None;
let error = query(&context.guard, &context.caller, request).await;
let error = error
.err()
.ok_or_else(|| WireError::backend("expected error"))?;
assert_eq!(error.code, WireErrorCode::NotFound);
assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
Ok(())
}
#[tokio::test]
async fn cancel_handler_scopes_then_invokes_engine_cancel()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
let error = cancel(
&context.state,
&context.guard,
&context.caller,
cancel_request(),
)
.await;
let error = error
.err()
.ok_or_else(|| WireError::backend("expected error"))?;
assert_eq!(error.code, WireErrorCode::NotFound);
assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
assert_eq!(
error.message,
format!("workflow {} not found", workflow_id())
);
Ok(())
}
#[tokio::test]
async fn reopen_handler_maps_missing_workflow_to_not_found()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
let error = reopen(&context.guard, &context.caller, reopen_request()).await;
let error = error
.err()
.ok_or_else(|| WireError::backend("expected error"))?;
assert_eq!(error.code, WireErrorCode::NotFound);
assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
Ok(())
}
#[tokio::test]
async fn reopen_handler_rejects_completed_workflow_as_invalid_state()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
append_completed(context.store.as_ref()).await?;
let mut request = reopen_request();
request.run_id = None;
let error = reopen(&context.guard, &context.caller, request).await;
let error = error
.err()
.ok_or_else(|| WireError::backend("expected error"))?;
assert_eq!(error.code, WireErrorCode::InvalidState);
assert_eq!(error.error_type.as_deref(), Some("InvalidState"));
Ok(())
}
#[tokio::test]
async fn reopen_handler_rejects_timed_out_workflow_as_invalid_state()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
append_timed_out(context.store.as_ref()).await?;
let mut request = reopen_request();
request.run_id = None;
let error = reopen(&context.guard, &context.caller, request).await;
let error = error
.err()
.ok_or_else(|| WireError::backend("expected error"))?;
assert_eq!(error.code, WireErrorCode::InvalidState);
assert_eq!(error.error_type.as_deref(), Some("InvalidState"));
Ok(())
}
#[tokio::test]
async fn reopen_handler_maps_omitted_run_missing_workflow_to_not_found()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
let mut request = reopen_request();
request.run_id = None;
let error = reopen(&context.guard, &context.caller, request).await;
assert_workflow_not_found(error)?;
Ok(())
}
#[tokio::test]
async fn denied_reopen_is_namespace_denied_before_engine_check()
-> Result<(), Box<dyn std::error::Error>> {
let (guard, caller) = denied_guard();
let request = ProtoReopenRequest {
namespace: NAMESPACE.to_owned(),
workflow_id: Some(workflow_id().into()),
run_id: Some(run_id().into()),
};
let error = reopen(&guard, &caller, request).await;
assert_eq!(
error.err().map(|error| error.code),
Some(WireErrorCode::NamespaceDenied)
);
Ok(())
}
#[tokio::test]
async fn signal_handler_rejects_completed_workflow() -> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
append_completed(context.store.as_ref()).await?;
let error = signal(&context.guard, &context.caller, signal_request()?).await;
let error = error
.err()
.ok_or_else(|| WireError::backend("expected error"))?;
assert_eq!(error.code, WireErrorCode::NotRunning);
assert_eq!(error.error_type.as_deref(), Some("WorkflowTerminal"));
assert_eq!(
error.message,
format!(
"workflow {} has already reached terminal state Completed",
workflow_id()
)
);
Ok(())
}
#[tokio::test]
async fn signal_handler_rejects_failed_workflow() -> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
append_failed(context.store.as_ref()).await?;
let error = signal(&context.guard, &context.caller, signal_request()?).await;
let error = error
.err()
.ok_or_else(|| WireError::backend("expected error"))?;
assert_eq!(error.code, WireErrorCode::NotRunning);
assert_eq!(error.error_type.as_deref(), Some("WorkflowTerminal"));
assert_eq!(
error.message,
format!(
"workflow {} has already reached terminal state Failed",
workflow_id()
)
);
Ok(())
}
#[tokio::test]
async fn cancel_handler_rejects_completed_workflow() -> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
append_completed(context.store.as_ref()).await?;
let error = cancel(
&context.state,
&context.guard,
&context.caller,
cancel_request(),
)
.await;
let error = error
.err()
.ok_or_else(|| WireError::backend("expected error"))?;
assert_eq!(error.code, WireErrorCode::NotRunning);
assert_eq!(error.error_type.as_deref(), Some("WorkflowTerminal"));
assert_eq!(
error.message,
format!(
"workflow {} has already completed with status Completed",
workflow_id()
)
);
assert!(!error.message.contains("process 0 is not live"));
Ok(())
}
#[tokio::test]
async fn cancel_handler_rejects_failed_workflow() -> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
append_failed(context.store.as_ref()).await?;
let error = cancel(
&context.state,
&context.guard,
&context.caller,
cancel_request(),
)
.await;
let error = error
.err()
.ok_or_else(|| WireError::backend("expected error"))?;
assert_eq!(error.code, WireErrorCode::NotRunning);
assert_eq!(error.error_type.as_deref(), Some("WorkflowTerminal"));
assert_eq!(
error.message,
format!(
"workflow {} has already completed with status Failed",
workflow_id()
)
);
assert!(!error.message.contains("process 0 is not live"));
Ok(())
}
#[tokio::test]
async fn signal_handler_maps_omitted_run_missing_workflow_to_not_found()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
let mut request = signal_request()?;
request.run_id = None;
let error = signal(&context.guard, &context.caller, request).await;
assert_workflow_not_found(error)?;
Ok(())
}
#[tokio::test]
async fn query_handler_maps_omitted_run_missing_workflow_to_not_found()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
let mut request = query_request();
request.run_id = None;
let error = query(&context.guard, &context.caller, request).await;
assert_workflow_not_found(error)?;
Ok(())
}
#[tokio::test]
async fn cancel_handler_maps_omitted_run_missing_workflow_to_not_found()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
context.ownership.record(workflow_id(), NAMESPACE)?;
let mut request = cancel_request();
request.run_id = None;
let error = cancel(&context.state, &context.guard, &context.caller, request).await;
assert_workflow_not_found(error)?;
Ok(())
}
#[tokio::test]
async fn denied_start_does_not_decode_missing_payload_before_namespace_check()
-> Result<(), Box<dyn std::error::Error>> {
let (guard, caller) = denied_guard();
let request = ProtoStartWorkflowRequest {
namespace: NAMESPACE.to_owned(),
workflow_type: "fixture".to_owned(),
input: None,
routing_key: None,
task_queue: None,
display_name: None,
};
let error = start(&guard, &caller, request).await;
assert_eq!(
error.err().map(|error| error.code),
Some(WireErrorCode::NamespaceDenied)
);
Ok(())
}
#[tokio::test]
async fn denied_signal_does_not_decode_missing_payload_before_namespace_check()
-> Result<(), Box<dyn std::error::Error>> {
let (guard, caller) = denied_guard();
let request = ProtoSignalRequest {
namespace: NAMESPACE.to_owned(),
workflow_id: Some(workflow_id().into()),
run_id: Some(run_id().into()),
signal_name: "poke".to_owned(),
payload: None,
};
let error = signal(&guard, &caller, request).await;
assert_eq!(
error.err().map(|error| error.code),
Some(WireErrorCode::NamespaceDenied)
);
Ok(())
}
use std::sync::Arc;
use aion_store::{NamespaceOrigin, NamespaceStore};
use crate::config::AutoCreate;
fn namespace_store() -> Arc<dyn NamespaceStore> {
Arc::new(aion_store::InMemoryStore::default())
}
fn minter(store: &Arc<dyn NamespaceStore>, policy: AutoCreate) -> NamespaceMinter {
NamespaceMinter::new(Arc::clone(store), policy)
}
fn fresh_start_request() -> Result<ProtoStartWorkflowRequest, aion_core::PayloadError> {
Ok(ProtoStartWorkflowRequest {
namespace: NAMESPACE.to_owned(),
workflow_type: "missing-workflow".to_owned(),
input: Some(proto_payload()?),
routing_key: None,
task_queue: None,
display_name: None,
})
}
#[tokio::test]
async fn open_start_mints_durable_record_and_is_idempotent()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
let store = namespace_store();
let minter = minter(&store, AutoCreate::Open);
assert!(store.get_namespace(NAMESPACE).await?.is_none());
let first = start_with_placement(
&context.guard,
&context.caller,
fresh_start_request()?,
None,
Some(&minter),
)
.await;
assert!(
first.is_err(),
"the fixture start has no registered workflow type"
);
let record = store
.get_namespace(NAMESPACE)
.await?
.ok_or("expected a durable record minted by the start")?;
assert_eq!(record.name, NAMESPACE);
assert_eq!(record.origin, NamespaceOrigin::StartMint);
let _second = start_with_placement(
&context.guard,
&context.caller,
fresh_start_request()?,
None,
Some(&minter),
)
.await;
let all = store.list_namespaces().await?;
assert_eq!(
all.iter().filter(|r| r.name == NAMESPACE).count(),
1,
"a second start must not create a duplicate namespace row"
);
Ok(())
}
#[tokio::test]
async fn closed_start_rejects_unknown_namespace_and_does_not_create_it()
-> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
let store = namespace_store();
let minter = minter(&store, AutoCreate::Closed);
let denied = start_with_placement(
&context.guard,
&context.caller,
fresh_start_request()?,
None,
Some(&minter),
)
.await;
let error = denied
.err()
.ok_or_else(|| WireError::backend("expected a namespace-denied error"))?;
assert_eq!(error.code, WireErrorCode::NamespaceDenied);
assert!(
store.get_namespace(NAMESPACE).await?.is_none(),
"closed policy must NOT create the namespace it rejected"
);
Ok(())
}
#[tokio::test]
async fn closed_start_admits_a_known_namespace() -> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
let store = namespace_store();
store
.register_namespace(NAMESPACE, NamespaceOrigin::Explicit)
.await?;
let minter = minter(&store, AutoCreate::Closed);
let error = start_with_placement(
&context.guard,
&context.caller,
fresh_start_request()?,
None,
Some(&minter),
)
.await
.err()
.ok_or_else(|| WireError::backend("expected the fixture workflow-type miss"))?;
assert_eq!(
error.code,
WireErrorCode::NotFound,
"a known namespace must pass the gate and fail only at the engine"
);
assert_eq!(error.error_type.as_deref(), Some("WorkflowTypeNotFound"));
Ok(())
}
#[tokio::test]
async fn no_minter_leaves_start_untouched() -> Result<(), Box<dyn std::error::Error>> {
let context = context().await?;
let error = start_with_placement(
&context.guard,
&context.caller,
fresh_start_request()?,
None,
None,
)
.await
.err()
.ok_or_else(|| WireError::backend("expected the fixture workflow-type miss"))?;
assert_eq!(error.code, WireErrorCode::NotFound);
assert_eq!(error.error_type.as_deref(), Some("WorkflowTypeNotFound"));
Ok(())
}
}