use aion_core::{Event, WorkflowStatus};
use crate::namespace::{NAMESPACE_ATTRIBUTE, TASK_QUEUE_ATTRIBUTE};
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SubscriptionSelector {
pub workflow_type: Option<String>,
pub status: Option<WorkflowStatus>,
}
impl SubscriptionSelector {
#[must_use]
pub const fn unrestricted() -> Self {
Self {
workflow_type: None,
status: None,
}
}
#[must_use]
pub fn matches(&self, event: &Event, workflow_type: Option<&str>) -> bool {
if let Some(selected_type) = &self.workflow_type {
if workflow_type != Some(selected_type.as_str()) {
return false;
}
}
if let Some(selected_status) = self.status {
if event_status(event).is_some_and(|status| status != selected_status) {
return false;
}
}
true
}
}
fn event_status(event: &Event) -> Option<WorkflowStatus> {
match event {
Event::WorkflowCompleted { .. } => Some(WorkflowStatus::Completed),
Event::WorkflowFailed { .. } => Some(WorkflowStatus::Failed),
Event::WorkflowCancelled { .. } => Some(WorkflowStatus::Cancelled),
Event::WorkflowTimedOut { .. } => Some(WorkflowStatus::TimedOut),
Event::WorkflowContinuedAsNew { .. } => Some(WorkflowStatus::ContinuedAsNew),
Event::WorkflowPaused { .. } => Some(WorkflowStatus::Paused),
Event::SearchAttributesUpdated { attributes, .. } => {
if is_start_time_stamp(attributes) {
Some(WorkflowStatus::Running)
} else {
None
}
}
Event::WorkflowStarted { .. }
| Event::WorkflowReopened { .. }
| Event::WorkflowResumed { .. }
| Event::ActivityScheduled { .. }
| Event::ActivityStarted { .. }
| Event::ActivityAdoptionOffered { .. }
| Event::ActivityCompleted { .. }
| Event::ActivityFailed { .. }
| Event::ActivityAdvisoryExhausted { .. }
| Event::ActivityCancelled { .. }
| Event::TimerStarted { .. }
| Event::TimerFired { .. }
| Event::TimerCancelled { .. }
| Event::WithTimeoutCompleted { .. }
| Event::SignalReceived { .. }
| Event::SignalSent { .. }
| Event::ChildWorkflowStarted { .. }
| Event::ChildWorkflowCompleted { .. }
| Event::ChildWorkflowFailed { .. }
| Event::ChildWorkflowCancelled { .. }
| Event::ScheduleCreated { .. }
| Event::ScheduleUpdated { .. }
| Event::SchedulePaused { .. }
| Event::ScheduleResumed { .. }
| Event::ScheduleDeleted { .. }
| Event::ScheduleTriggered { .. } => Some(WorkflowStatus::Running),
}
}
fn is_start_time_stamp(
attributes: &std::collections::HashMap<String, aion_core::SearchAttributeValue>,
) -> bool {
attributes.contains_key(NAMESPACE_ATTRIBUTE) || attributes.contains_key(TASK_QUEUE_ATTRIBUTE)
}
#[cfg(test)]
mod tests {
use aion_core::{Event, EventEnvelope, Payload, WorkflowId, WorkflowStatus};
use super::SubscriptionSelector;
fn envelope(seq: u64) -> EventEnvelope {
EventEnvelope {
seq,
recorded_at: chrono::Utc::now(),
workflow_id: WorkflowId::new(uuid::Uuid::from_u128(1)),
}
}
fn payload() -> Result<Payload, aion_core::PayloadError> {
Payload::from_json(&serde_json::json!({ "label": "x" }))
}
fn signal(seq: u64) -> Result<Event, aion_core::PayloadError> {
Ok(Event::SignalReceived {
envelope: envelope(seq),
name: "ship".to_owned(),
payload: payload()?,
})
}
fn started(seq: u64) -> Result<Event, aion_core::PayloadError> {
Ok(Event::WorkflowStarted {
envelope: envelope(seq),
workflow_type: "checkout".to_owned(),
input: payload()?,
run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
parent_run_id: None,
package_version: aion_core::PackageVersion::new("a".repeat(64)),
})
}
fn completed(seq: u64) -> Result<Event, aion_core::PayloadError> {
Ok(Event::WorkflowCompleted {
envelope: envelope(seq),
result: payload()?,
})
}
fn failed(seq: u64) -> Event {
Event::WorkflowFailed {
envelope: envelope(seq),
error: aion_core::WorkflowError {
message: "boom".to_owned(),
details: None,
},
}
}
fn renamed(seq: u64) -> Event {
Event::SearchAttributesUpdated {
envelope: envelope(seq),
workflow_id: WorkflowId::new(uuid::Uuid::from_u128(1)),
attributes: std::collections::HashMap::from([(
aion_core::DISPLAY_NAME_ATTRIBUTE.to_owned(),
aion_core::SearchAttributeValue::String("Nightly settlement".to_owned()),
)]),
}
}
fn start_stamp(seq: u64) -> Event {
Event::SearchAttributesUpdated {
envelope: envelope(seq),
workflow_id: WorkflowId::new(uuid::Uuid::from_u128(1)),
attributes: std::collections::HashMap::from([
(
crate::namespace::NAMESPACE_ATTRIBUTE.to_owned(),
aion_core::SearchAttributeValue::String("default".to_owned()),
),
(
crate::namespace::TASK_QUEUE_ATTRIBUTE.to_owned(),
aion_core::SearchAttributeValue::String("settlement".to_owned()),
),
(
aion_core::DISPLAY_NAME_ATTRIBUTE.to_owned(),
aion_core::SearchAttributeValue::String("Nightly settlement".to_owned()),
),
]),
}
}
#[test]
fn a_start_time_attribute_stamp_stays_in_the_running_bucket() {
let running = SubscriptionSelector {
workflow_type: None,
status: Some(WorkflowStatus::Running),
};
assert!(
running.matches(&start_stamp(2), Some("checkout")),
"the start-time stamp accompanies a start, so it belongs to Running"
);
for status in [
WorkflowStatus::Completed,
WorkflowStatus::Failed,
WorkflowStatus::Cancelled,
WorkflowStatus::TimedOut,
WorkflowStatus::ContinuedAsNew,
WorkflowStatus::Paused,
] {
let selector = SubscriptionSelector {
workflow_type: None,
status: Some(status),
};
assert!(
!selector.matches(&start_stamp(2), Some("checkout")),
"a status={status:?} subscriber must not receive an attribute frame for every \
workflow STARTING in the namespace"
);
}
let completed_only = SubscriptionSelector {
workflow_type: None,
status: Some(WorkflowStatus::Completed),
};
assert!(
completed_only.matches(&renamed(1), Some("checkout")),
"a label-only rename must still reach every status subscriber"
);
}
#[test]
fn a_rename_reaches_every_status_subscriber() -> Result<(), Box<dyn std::error::Error>> {
for status in [
WorkflowStatus::Running,
WorkflowStatus::Completed,
WorkflowStatus::Failed,
WorkflowStatus::Cancelled,
WorkflowStatus::TimedOut,
WorkflowStatus::ContinuedAsNew,
WorkflowStatus::Paused,
] {
let selector = SubscriptionSelector {
workflow_type: None,
status: Some(status),
};
assert!(
selector.matches(&renamed(1), Some("checkout")),
"a rename must reach a status={status:?} subscriber"
);
}
let typed = SubscriptionSelector {
workflow_type: Some("checkout".to_owned()),
status: Some(WorkflowStatus::Completed),
};
assert!(!typed.matches(&renamed(1), Some("payments")));
assert!(!typed.matches(&signal(2)?, Some("checkout")));
Ok(())
}
#[test]
fn unrestricted_selector_matches_everything() -> Result<(), Box<dyn std::error::Error>> {
let selector = SubscriptionSelector::unrestricted();
assert!(selector.matches(&signal(1)?, None));
assert!(selector.matches(&completed(2)?, Some("checkout")));
Ok(())
}
#[test]
fn type_selector_matches_only_the_recorded_type() -> Result<(), Box<dyn std::error::Error>> {
let selector = SubscriptionSelector {
workflow_type: Some("checkout".to_owned()),
status: None,
};
assert!(selector.matches(&signal(1)?, Some("checkout")));
assert!(!selector.matches(&signal(1)?, Some("fulfillment")));
assert!(
!selector.matches(&signal(1)?, None),
"a workflow with no recorded type never matches a type selector"
);
Ok(())
}
#[test]
fn status_selector_matches_per_event_kind() -> Result<(), Box<dyn std::error::Error>> {
let running = SubscriptionSelector {
workflow_type: None,
status: Some(WorkflowStatus::Running),
};
let completed_only = SubscriptionSelector {
workflow_type: None,
status: Some(WorkflowStatus::Completed),
};
let failed_only = SubscriptionSelector {
workflow_type: None,
status: Some(WorkflowStatus::Failed),
};
assert!(running.matches(&started(1)?, Some("checkout")));
assert!(running.matches(&signal(2)?, Some("checkout")));
assert!(!running.matches(&completed(3)?, Some("checkout")));
assert!(completed_only.matches(&completed(3)?, Some("checkout")));
assert!(!completed_only.matches(&failed(3), Some("checkout")));
assert!(!completed_only.matches(&signal(2)?, Some("checkout")));
assert!(failed_only.matches(&failed(3), Some("checkout")));
assert!(!failed_only.matches(&completed(3)?, Some("checkout")));
Ok(())
}
fn timed_out(seq: u64) -> Event {
Event::WorkflowTimedOut {
envelope: envelope(seq),
timeout: "workflow".to_owned(),
}
}
#[test]
fn status_selector_projects_workflow_timed_out_to_timed_out()
-> Result<(), Box<dyn std::error::Error>> {
let timed_out_only = SubscriptionSelector {
workflow_type: None,
status: Some(WorkflowStatus::TimedOut),
};
let running = SubscriptionSelector {
workflow_type: None,
status: Some(WorkflowStatus::Running),
};
let failed_only = SubscriptionSelector {
workflow_type: None,
status: Some(WorkflowStatus::Failed),
};
assert!(timed_out_only.matches(&timed_out(4), Some("checkout")));
assert!(!timed_out_only.matches(&completed(3)?, Some("checkout")));
assert!(!running.matches(&timed_out(4), Some("checkout")));
assert!(!failed_only.matches(&timed_out(4), Some("checkout")));
Ok(())
}
#[test]
fn combined_selectors_and_together() -> Result<(), Box<dyn std::error::Error>> {
let selector = SubscriptionSelector {
workflow_type: Some("checkout".to_owned()),
status: Some(WorkflowStatus::Completed),
};
assert!(selector.matches(&completed(3)?, Some("checkout")));
assert!(
!selector.matches(&completed(3)?, Some("fulfillment")),
"matching status with mismatched type must not pass"
);
assert!(
!selector.matches(&signal(2)?, Some("checkout")),
"matching type with mismatched status must not pass"
);
Ok(())
}
}