use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{
ActivityError, ActivityId, PackageVersion, Payload, RunId, ScheduleConfig, ScheduleId,
SearchAttributeValue, TimerId, WorkerAttribution, WorkflowError, WorkflowId,
};
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
pub struct EventEnvelope {
pub seq: u64,
pub recorded_at: DateTime<Utc>,
pub workflow_id: WorkflowId,
}
pub const DEFAULT_TASK_QUEUE: &str = "default";
fn default_task_queue() -> String {
String::from(DEFAULT_TASK_QUEUE)
}
const LEGACY_ACTIVITY_ATTEMPT: u32 = 0;
fn legacy_activity_attempt() -> u32 {
LEGACY_ACTIVITY_ATTEMPT
}
pub const START_TIME_TASK_QUEUE_ATTRIBUTE: &str = "aion.task_queue";
#[must_use]
pub fn start_time_task_queue(events: &[Event]) -> Option<String> {
let attributes = crate::search_attributes_from_events(events);
match attributes.get(START_TIME_TASK_QUEUE_ATTRIBUTE) {
Some(crate::SearchAttributeValue::String(queue)) => Some(queue.clone()),
_ => None,
}
}
pub const DISPLAY_NAME_ATTRIBUTE: &str = "aion.display_name";
#[must_use]
pub fn display_name(events: &[Event]) -> Option<String> {
display_name_from_attributes(&crate::search_attributes_from_events(events))
}
#[must_use]
pub fn display_name_from_attributes<S: std::hash::BuildHasher>(
attributes: &std::collections::HashMap<String, crate::SearchAttributeValue, S>,
) -> Option<String> {
match attributes.get(DISPLAY_NAME_ATTRIBUTE) {
Some(crate::SearchAttributeValue::String(name)) => Some(name.clone()),
_ => None,
}
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq)]
#[serde(tag = "type", content = "data")]
pub enum Event {
WorkflowStarted {
envelope: EventEnvelope,
workflow_type: String,
input: Payload,
run_id: RunId,
parent_run_id: Option<RunId>,
#[serde(default)]
parent_workflow_id: Option<WorkflowId>,
package_version: PackageVersion,
},
WorkflowCompleted {
envelope: EventEnvelope,
result: Payload,
},
WorkflowFailed {
envelope: EventEnvelope,
error: WorkflowError,
},
WorkflowCancelled {
envelope: EventEnvelope,
reason: String,
},
WorkflowTimedOut {
envelope: EventEnvelope,
timeout: String,
},
WorkflowContinuedAsNew {
envelope: EventEnvelope,
input: Payload,
workflow_type: Option<String>,
parent_run_id: RunId,
},
WorkflowReopened {
envelope: EventEnvelope,
run_id: RunId,
reopened: Vec<ActivityId>,
},
WorkflowPaused {
envelope: EventEnvelope,
run_id: RunId,
reason: Option<String>,
operator: Option<String>,
},
WorkflowResumed {
envelope: EventEnvelope,
run_id: RunId,
operator: Option<String>,
},
SearchAttributesUpdated {
envelope: EventEnvelope,
workflow_id: WorkflowId,
attributes: HashMap<String, SearchAttributeValue>,
},
ActivityScheduled {
envelope: EventEnvelope,
activity_id: ActivityId,
activity_type: String,
input: Payload,
#[serde(default = "default_task_queue")]
task_queue: String,
#[serde(default)]
node: Option<String>,
},
ActivityStarted {
envelope: EventEnvelope,
activity_id: ActivityId,
#[serde(default = "legacy_activity_attempt")]
attempt: u32,
},
ActivityLeased {
envelope: EventEnvelope,
activity_id: ActivityId,
attempt: u32,
worker: WorkerAttribution,
},
ActivityAdoptionOffered {
envelope: EventEnvelope,
activity_id: ActivityId,
attempt: u32,
},
ActivityCompleted {
envelope: EventEnvelope,
activity_id: ActivityId,
result: Payload,
#[serde(default = "legacy_activity_attempt")]
attempt: u32,
},
ActivityFailed {
envelope: EventEnvelope,
activity_id: ActivityId,
error: ActivityError,
attempt: u32,
},
ActivityAdvisoryExhausted {
envelope: EventEnvelope,
activity_id: ActivityId,
activity_type: String,
reason: String,
attempt: u32,
},
ActivityFallbackRouted {
envelope: EventEnvelope,
activity_id: ActivityId,
attempt: u32,
from_task_queue: String,
to_task_queue: String,
fallback_index: u32,
},
ActivityCancelled {
envelope: EventEnvelope,
activity_id: ActivityId,
#[serde(default = "legacy_activity_attempt")]
attempt: u32,
},
TimerStarted {
envelope: EventEnvelope,
timer_id: TimerId,
fire_at: DateTime<Utc>,
},
TimerFired {
envelope: EventEnvelope,
timer_id: TimerId,
},
TimerCancelled {
envelope: EventEnvelope,
timer_id: TimerId,
#[serde(default)]
cause: TimerCancelCause,
},
WithTimeoutCompleted {
envelope: EventEnvelope,
timer_id: TimerId,
outcome: WithTimeoutOutcome,
result: Option<Payload>,
},
SignalReceived {
envelope: EventEnvelope,
name: String,
payload: Payload,
},
SignalSent {
envelope: EventEnvelope,
target_workflow_id: WorkflowId,
name: String,
payload: Payload,
},
ChildWorkflowStarted {
envelope: EventEnvelope,
child_workflow_id: WorkflowId,
workflow_type: String,
input: Payload,
package_version: PackageVersion,
},
ChildWorkflowCompleted {
envelope: EventEnvelope,
child_workflow_id: WorkflowId,
result: Payload,
},
ChildWorkflowFailed {
envelope: EventEnvelope,
child_workflow_id: WorkflowId,
error: WorkflowError,
},
ChildWorkflowCancelled {
envelope: EventEnvelope,
child_workflow_id: WorkflowId,
},
ScheduleCreated {
envelope: EventEnvelope,
schedule_id: ScheduleId,
config: ScheduleConfig,
},
ScheduleUpdated {
envelope: EventEnvelope,
schedule_id: ScheduleId,
config: ScheduleConfig,
},
SchedulePaused {
envelope: EventEnvelope,
schedule_id: ScheduleId,
},
ScheduleResumed {
envelope: EventEnvelope,
schedule_id: ScheduleId,
},
ScheduleDeleted {
envelope: EventEnvelope,
schedule_id: ScheduleId,
},
ScheduleTriggered {
envelope: EventEnvelope,
schedule_id: ScheduleId,
workflow_id: WorkflowId,
run_id: RunId,
},
CadenceFired {
envelope: EventEnvelope,
window_seq: u64,
},
IterationClosed {
envelope: EventEnvelope,
routes: Vec<String>,
health_samples: Vec<crate::HealthSample>,
},
LoopRetired {
envelope: EventEnvelope,
reason: String,
},
WorkflowHatched {
envelope: EventEnvelope,
child_workflow_id: WorkflowId,
key: String,
},
InvariantUnconfirmed {
envelope: EventEnvelope,
invariant: String,
cause: crate::AlarmCause,
window_seq: Option<u64>,
last_confirmed_at: Option<DateTime<Utc>>,
consecutive_unconfirmed: u64,
},
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
pub enum WithTimeoutOutcome {
OperationCompleted,
TimedOut,
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TimerCancelCause {
#[default]
WorkflowIntent,
CancelTeardown,
}
impl Event {
#[must_use]
pub const fn envelope(&self) -> &EventEnvelope {
match self {
Self::WorkflowStarted { envelope, .. }
| Self::WorkflowCompleted { envelope, .. }
| Self::WorkflowFailed { envelope, .. }
| Self::WorkflowCancelled { envelope, .. }
| Self::WorkflowTimedOut { envelope, .. }
| Self::WorkflowContinuedAsNew { envelope, .. }
| Self::WorkflowReopened { envelope, .. }
| Self::WorkflowPaused { envelope, .. }
| Self::WorkflowResumed { envelope, .. }
| Self::SearchAttributesUpdated { envelope, .. }
| Self::ActivityScheduled { envelope, .. }
| Self::ActivityStarted { envelope, .. }
| Self::ActivityLeased { envelope, .. }
| Self::ActivityAdoptionOffered { envelope, .. }
| Self::ActivityCompleted { envelope, .. }
| Self::ActivityFailed { envelope, .. }
| Self::ActivityAdvisoryExhausted { envelope, .. }
| Self::ActivityFallbackRouted { envelope, .. }
| Self::ActivityCancelled { envelope, .. }
| Self::TimerStarted { envelope, .. }
| Self::TimerFired { envelope, .. }
| Self::TimerCancelled { envelope, .. }
| Self::WithTimeoutCompleted { envelope, .. }
| Self::SignalReceived { envelope, .. }
| Self::SignalSent { envelope, .. }
| Self::ChildWorkflowStarted { envelope, .. }
| Self::ChildWorkflowCompleted { envelope, .. }
| Self::ChildWorkflowFailed { envelope, .. }
| Self::ChildWorkflowCancelled { envelope, .. }
| Self::ScheduleCreated { envelope, .. }
| Self::ScheduleUpdated { envelope, .. }
| Self::SchedulePaused { envelope, .. }
| Self::ScheduleResumed { envelope, .. }
| Self::ScheduleDeleted { envelope, .. }
| Self::ScheduleTriggered { envelope, .. }
| Self::CadenceFired { envelope, .. }
| Self::IterationClosed { envelope, .. }
| Self::LoopRetired { envelope, .. }
| Self::WorkflowHatched { envelope, .. }
| Self::InvariantUnconfirmed { envelope, .. } => envelope,
}
}
#[must_use]
pub const fn seq(&self) -> u64 {
self.envelope().seq
}
#[must_use]
pub const fn recorded_at(&self) -> &DateTime<Utc> {
&self.envelope().recorded_at
}
#[must_use]
pub const fn workflow_id(&self) -> &WorkflowId {
&self.envelope().workflow_id
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde_json::json;
use super::{
DEFAULT_TASK_QUEUE, Event, EventEnvelope, LEGACY_ACTIVITY_ATTEMPT, TimerCancelCause,
};
use crate::{
ActivityError, ActivityErrorKind, ActivityId, CatchUpPolicy, OverlapPolicy, PackageVersion,
Payload, RunId, ScheduleConfig, ScheduleId, SearchAttributeValue, TimerId, TriggerSpec,
WorkerAttribution, WorkerTransport, WorkflowError, WorkflowId,
};
fn package_version() -> PackageVersion {
PackageVersion::new("a".repeat(64))
}
fn recorded_at() -> DateTime<Utc> {
DateTime::from_timestamp(1_700_000_000, 123_000_000).unwrap_or_default()
}
fn envelope(seq: u64) -> EventEnvelope {
EventEnvelope {
seq,
recorded_at: recorded_at(),
workflow_id: WorkflowId::new(uuid::Uuid::nil()),
}
}
fn payload(label: &str) -> Result<Payload, crate::PayloadError> {
Payload::from_json(&json!({ "label": label }))
}
fn schedule_config(label: &str) -> Result<ScheduleConfig, crate::PayloadError> {
Ok(ScheduleConfig {
trigger: TriggerSpec::Cron {
expression: String::from("0 0 * * *"),
},
overlap_policy: OverlapPolicy::Skip,
catch_up_policy: CatchUpPolicy::One,
workflow_type: String::from("checkout"),
input: payload(label)?,
search_attributes: HashMap::from([(
String::from("aion.namespace"),
crate::SearchAttributeValue::String(String::from("tenant-a")),
)]),
})
}
fn workflow_error(message: &str) -> WorkflowError {
WorkflowError {
message: String::from(message),
details: None,
}
}
fn activity_error(kind: ActivityErrorKind, message: &str) -> ActivityError {
ActivityError {
kind,
message: String::from(message),
details: None,
}
}
fn round_trip(event: &Event) -> Result<(), serde_json::Error> {
let json = serde_json::to_string(event)?;
let decoded = serde_json::from_str::<Event>(&json)?;
assert_eq!(*event, decoded);
Ok(())
}
#[test]
fn activity_scheduled_records_and_reads_back_its_task_queue()
-> Result<(), Box<dyn std::error::Error>> {
let event = Event::ActivityScheduled {
envelope: envelope(6),
activity_id: ActivityId::from_sequence_position(6),
activity_type: String::from("charge-card"),
input: payload("activity-input")?,
task_queue: String::from("claude"),
node: None,
};
let json = serde_json::to_string(&event)?;
let decoded = serde_json::from_str::<Event>(&json)?;
match decoded {
Event::ActivityScheduled { task_queue, .. } => {
assert_eq!(
task_queue, "claude",
"the recorded task queue must survive the round-trip"
);
}
other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
}
Ok(())
}
#[test]
fn activity_scheduled_decodes_old_history_without_task_queue_as_default()
-> Result<(), Box<dyn std::error::Error>> {
let current = Event::ActivityScheduled {
envelope: envelope(6),
activity_id: ActivityId::from_sequence_position(6),
activity_type: String::from("charge-card"),
input: payload("activity-input")?,
task_queue: String::from("ignored-when-stripped"),
node: Some(String::from("ignored-when-stripped")),
};
let mut value = serde_json::to_value(¤t)?;
let data = value
.get_mut("data")
.and_then(serde_json::Value::as_object_mut)
.ok_or("ActivityScheduled must serialize to a tagged object with a `data` map")?;
assert!(
data.remove("task_queue").is_some(),
"the current wire form must contain task_queue before we strip it"
);
let old_wire = serde_json::to_string(&value)?;
for _ in 0..4 {
let decoded = serde_json::from_str::<Event>(&old_wire)?;
match &decoded {
Event::ActivityScheduled { task_queue, .. } => {
assert_eq!(
task_queue, DEFAULT_TASK_QUEUE,
"a missing task_queue must default to the named default queue"
);
assert_eq!(task_queue, "default");
}
other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
}
}
Ok(())
}
#[test]
fn activity_scheduled_records_and_reads_back_its_node() -> Result<(), Box<dyn std::error::Error>>
{
let event = Event::ActivityScheduled {
envelope: envelope(6),
activity_id: ActivityId::from_sequence_position(6),
activity_type: String::from("charge-card"),
input: payload("activity-input")?,
task_queue: String::from("claude"),
node: Some(String::from("box-7")),
};
let json = serde_json::to_string(&event)?;
let decoded = serde_json::from_str::<Event>(&json)?;
match decoded {
Event::ActivityScheduled { node, .. } => {
assert_eq!(
node.as_deref(),
Some("box-7"),
"the recorded node affinity must survive the round-trip"
);
}
other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
}
Ok(())
}
#[test]
fn activity_scheduled_decodes_old_history_without_node_as_none()
-> Result<(), Box<dyn std::error::Error>> {
let current = Event::ActivityScheduled {
envelope: envelope(6),
activity_id: ActivityId::from_sequence_position(6),
activity_type: String::from("charge-card"),
input: payload("activity-input")?,
task_queue: String::from("default"),
node: Some(String::from("ignored-when-stripped")),
};
let mut value = serde_json::to_value(¤t)?;
let data = value
.get_mut("data")
.and_then(serde_json::Value::as_object_mut)
.ok_or("ActivityScheduled must serialize to a tagged object with a `data` map")?;
assert!(
data.remove("node").is_some(),
"the current wire form must contain node before we strip it"
);
let old_wire = serde_json::to_string(&value)?;
for _ in 0..4 {
let decoded = serde_json::from_str::<Event>(&old_wire)?;
match &decoded {
Event::ActivityScheduled { node, .. } => {
assert_eq!(
*node, None,
"a missing node must default to None (no affinity)"
);
}
other => return Err(format!("expected ActivityScheduled, got {other:?}").into()),
}
}
Ok(())
}
#[test]
fn activity_lifecycle_records_and_reads_back_its_attempt()
-> Result<(), Box<dyn std::error::Error>> {
let started = Event::ActivityStarted {
envelope: envelope(7),
activity_id: ActivityId::from_sequence_position(6),
attempt: 3,
};
let completed = Event::ActivityCompleted {
envelope: envelope(8),
activity_id: ActivityId::from_sequence_position(6),
result: payload("activity-result")?,
attempt: 3,
};
let cancelled = Event::ActivityCancelled {
envelope: envelope(9),
activity_id: ActivityId::from_sequence_position(6),
attempt: 3,
};
for event in [&started, &completed, &cancelled] {
round_trip(event)?;
}
match serde_json::from_str::<Event>(&serde_json::to_string(&started)?)? {
Event::ActivityStarted { attempt, .. } => assert_eq!(attempt, 3),
other => return Err(format!("expected ActivityStarted, got {other:?}").into()),
}
match serde_json::from_str::<Event>(&serde_json::to_string(&completed)?)? {
Event::ActivityCompleted { attempt, .. } => assert_eq!(attempt, 3),
other => return Err(format!("expected ActivityCompleted, got {other:?}").into()),
}
match serde_json::from_str::<Event>(&serde_json::to_string(&cancelled)?)? {
Event::ActivityCancelled { attempt, .. } => assert_eq!(attempt, 3),
other => return Err(format!("expected ActivityCancelled, got {other:?}").into()),
}
Ok(())
}
#[test]
fn activity_lifecycle_decodes_old_history_without_attempt_as_legacy_sentinel()
-> Result<(), Box<dyn std::error::Error>> {
let started = Event::ActivityStarted {
envelope: envelope(7),
activity_id: ActivityId::from_sequence_position(6),
attempt: 5,
};
let completed = Event::ActivityCompleted {
envelope: envelope(8),
activity_id: ActivityId::from_sequence_position(6),
result: payload("activity-result")?,
attempt: 5,
};
let cancelled = Event::ActivityCancelled {
envelope: envelope(9),
activity_id: ActivityId::from_sequence_position(6),
attempt: 5,
};
for current in [&started, &completed, &cancelled] {
let mut value = serde_json::to_value(current)?;
let data = value
.get_mut("data")
.and_then(serde_json::Value::as_object_mut)
.ok_or("activity lifecycle event must serialize to a tagged object with `data`")?;
assert!(
data.remove("attempt").is_some(),
"the current wire form must contain attempt before we strip it"
);
let old_wire = serde_json::to_string(&value)?;
for _ in 0..4 {
let decoded = serde_json::from_str::<Event>(&old_wire)?;
let attempt = match &decoded {
Event::ActivityStarted { attempt, .. }
| Event::ActivityCompleted { attempt, .. }
| Event::ActivityCancelled { attempt, .. } => *attempt,
other => {
return Err(
format!("expected an activity lifecycle event, got {other:?}").into(),
);
}
};
assert_eq!(
attempt, LEGACY_ACTIVITY_ATTEMPT,
"a missing attempt must default to the legacy sentinel (0)"
);
assert_eq!(attempt, 0);
}
}
Ok(())
}
#[test]
fn timer_cancelled_decodes_old_history_without_cause_as_workflow_intent()
-> Result<(), Box<dyn std::error::Error>> {
let cancelled = Event::TimerCancelled {
envelope: envelope(7),
timer_id: TimerId::named("deadline")?,
cause: TimerCancelCause::CancelTeardown,
};
let mut value = serde_json::to_value(&cancelled)?;
let data = value
.get_mut("data")
.and_then(serde_json::Value::as_object_mut)
.ok_or("TimerCancelled must serialize to a tagged object with `data`")?;
assert!(
data.remove("cause").is_some(),
"the current wire form must contain cause before we strip it"
);
let old_wire = serde_json::to_string(&value)?;
for _ in 0..4 {
let decoded = serde_json::from_str::<Event>(&old_wire)?;
match &decoded {
Event::TimerCancelled { cause, .. } => assert_eq!(
*cause,
TimerCancelCause::WorkflowIntent,
"a missing cause must default to WorkflowIntent (never resurrected)"
),
other => {
return Err(format!("expected TimerCancelled, got {other:?}").into());
}
}
}
Ok(())
}
#[test]
fn pause_resume_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
let events = vec![
Event::WorkflowPaused {
envelope: envelope(2),
run_id: RunId::new(uuid::Uuid::from_u128(1)),
reason: Some(String::from("operator hold")),
operator: Some(String::from("tom")),
},
Event::WorkflowPaused {
envelope: envelope(3),
run_id: RunId::new(uuid::Uuid::from_u128(1)),
reason: None,
operator: None,
},
Event::WorkflowResumed {
envelope: envelope(4),
run_id: RunId::new(uuid::Uuid::from_u128(1)),
operator: Some(String::from("tom")),
},
];
for event in &events {
round_trip(event)?;
}
Ok(())
}
#[test]
fn old_history_without_pause_resume_decodes_unchanged() -> Result<(), Box<dyn std::error::Error>>
{
let started = Event::WorkflowStarted {
envelope: envelope(1),
workflow_type: String::from("checkout"),
input: payload("input")?,
run_id: RunId::new(uuid::Uuid::from_u128(1)),
parent_run_id: None,
parent_workflow_id: None,
package_version: package_version(),
};
let completed = Event::WorkflowCompleted {
envelope: envelope(2),
result: payload("result")?,
};
let history = vec![started, completed];
let json = serde_json::to_string(&history)?;
let decoded = serde_json::from_str::<Vec<Event>>(&json)?;
assert_eq!(history, decoded);
Ok(())
}
#[test]
fn start_time_task_queue_projects_from_recorded_attribute()
-> Result<(), Box<dyn std::error::Error>> {
use super::{START_TIME_TASK_QUEUE_ATTRIBUTE, start_time_task_queue};
use crate::SearchAttributeValue;
let events = vec![
Event::WorkflowStarted {
envelope: envelope(1),
workflow_type: String::from("checkout"),
input: payload("input")?,
run_id: RunId::new(uuid::Uuid::from_u128(1)),
parent_run_id: None,
parent_workflow_id: None,
package_version: package_version(),
},
Event::SearchAttributesUpdated {
envelope: envelope(2),
workflow_id: WorkflowId::new(uuid::Uuid::nil()),
attributes: HashMap::from([(
START_TIME_TASK_QUEUE_ATTRIBUTE.to_owned(),
SearchAttributeValue::String(String::from("gpu")),
)]),
},
];
assert_eq!(start_time_task_queue(&events).as_deref(), Some("gpu"));
Ok(())
}
#[test]
fn start_time_task_queue_is_none_without_the_attribute()
-> Result<(), Box<dyn std::error::Error>> {
use super::start_time_task_queue;
let events = vec![Event::WorkflowStarted {
envelope: envelope(1),
workflow_type: String::from("checkout"),
input: payload("input")?,
run_id: RunId::new(uuid::Uuid::from_u128(1)),
parent_run_id: None,
parent_workflow_id: None,
package_version: package_version(),
}];
assert_eq!(start_time_task_queue(&events), None);
Ok(())
}
#[test]
fn display_name_projects_from_recorded_attribute() -> Result<(), Box<dyn std::error::Error>> {
use super::{DISPLAY_NAME_ATTRIBUTE, display_name};
use crate::SearchAttributeValue;
let events = vec![
Event::WorkflowStarted {
envelope: envelope(1),
workflow_type: String::from("checkout"),
input: payload("input")?,
run_id: RunId::new(uuid::Uuid::from_u128(1)),
parent_run_id: None,
parent_workflow_id: None,
package_version: package_version(),
},
Event::SearchAttributesUpdated {
envelope: envelope(2),
workflow_id: WorkflowId::new(uuid::Uuid::nil()),
attributes: HashMap::from([(
DISPLAY_NAME_ATTRIBUTE.to_owned(),
SearchAttributeValue::String(String::from("Nightly settlement")),
)]),
},
];
assert_eq!(display_name(&events).as_deref(), Some("Nightly settlement"));
Ok(())
}
#[test]
fn display_name_is_none_without_the_attribute() -> Result<(), Box<dyn std::error::Error>> {
use super::display_name;
let events = vec![Event::WorkflowStarted {
envelope: envelope(1),
workflow_type: String::from("checkout"),
input: payload("input")?,
run_id: RunId::new(uuid::Uuid::from_u128(1)),
parent_run_id: None,
parent_workflow_id: None,
package_version: package_version(),
}];
assert_eq!(display_name(&events), None);
Ok(())
}
#[test]
fn display_name_later_update_overrides_earlier() -> Result<(), Box<dyn std::error::Error>> {
use super::{DISPLAY_NAME_ATTRIBUTE, display_name};
use crate::SearchAttributeValue;
let name_event = |seq: u64, name: &str| Event::SearchAttributesUpdated {
envelope: envelope(seq),
workflow_id: WorkflowId::new(uuid::Uuid::nil()),
attributes: HashMap::from([(
DISPLAY_NAME_ATTRIBUTE.to_owned(),
SearchAttributeValue::String(String::from(name)),
)]),
};
let events = vec![
Event::WorkflowStarted {
envelope: envelope(1),
workflow_type: String::from("checkout"),
input: payload("input")?,
run_id: RunId::new(uuid::Uuid::from_u128(1)),
parent_run_id: None,
parent_workflow_id: None,
package_version: package_version(),
},
name_event(2, "first name"),
name_event(3, "second name"),
];
assert_eq!(display_name(&events).as_deref(), Some("second name"));
Ok(())
}
#[test]
fn event_accessors_return_envelope_fields() -> Result<(), Box<dyn std::error::Error>> {
let workflow_id = WorkflowId::new_v4();
let recorded_at = recorded_at();
let envelope = EventEnvelope {
seq: 17,
recorded_at,
workflow_id: workflow_id.clone(),
};
let event = Event::WorkflowStarted {
envelope,
workflow_type: String::from("checkout"),
input: payload("input")?,
run_id: RunId::new(uuid::Uuid::from_u128(1)),
parent_run_id: None,
parent_workflow_id: None,
package_version: package_version(),
};
assert_eq!(event.seq(), 17);
assert_eq!(event.recorded_at(), &recorded_at);
assert_eq!(event.workflow_id(), &workflow_id);
Ok(())
}
#[test]
fn events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
let fire_at = DateTime::from_timestamp(1_700_000_100, 0).unwrap_or_default();
let events = vec![
Event::WorkflowStarted {
envelope: envelope(1),
workflow_type: String::from("checkout"),
input: payload("workflow-input")?,
run_id: RunId::new(uuid::Uuid::from_u128(1)),
parent_run_id: None,
parent_workflow_id: None,
package_version: package_version(),
},
Event::WorkflowCompleted {
envelope: envelope(2),
result: payload("workflow-result")?,
},
Event::WorkflowFailed {
envelope: envelope(3),
error: workflow_error("workflow failed"),
},
Event::WorkflowCancelled {
envelope: envelope(4),
reason: String::from("caller requested cancellation"),
},
Event::WorkflowTimedOut {
envelope: envelope(5),
timeout: String::from("execution"),
},
Event::ActivityScheduled {
envelope: envelope(6),
activity_id: ActivityId::from_sequence_position(6),
activity_type: String::from("charge-card"),
input: payload("activity-input")?,
task_queue: String::from("claude"),
node: Some(String::from("box-7")),
},
Event::ActivityStarted {
envelope: envelope(7),
activity_id: ActivityId::from_sequence_position(6),
attempt: 1,
},
Event::ActivityCompleted {
envelope: envelope(8),
activity_id: ActivityId::from_sequence_position(6),
result: payload("activity-result")?,
attempt: 1,
},
Event::ActivityFailed {
envelope: envelope(9),
activity_id: ActivityId::from_sequence_position(6),
error: activity_error(ActivityErrorKind::Retryable, "temporary outage"),
attempt: 1,
},
Event::ActivityCancelled {
envelope: envelope(10),
activity_id: ActivityId::from_sequence_position(6),
attempt: 1,
},
Event::TimerStarted {
envelope: envelope(11),
timer_id: TimerId::anonymous(11),
fire_at,
},
Event::TimerFired {
envelope: envelope(12),
timer_id: TimerId::anonymous(11),
},
Event::TimerCancelled {
envelope: envelope(13),
timer_id: TimerId::named("reminder")?,
cause: TimerCancelCause::WorkflowIntent,
},
Event::SignalReceived {
envelope: envelope(14),
name: String::from("approve"),
payload: payload("signal")?,
},
Event::SignalSent {
envelope: envelope(15),
target_workflow_id: WorkflowId::new(uuid::Uuid::from_u128(5)),
name: String::from("approve"),
payload: payload("signal-sent")?,
},
];
for event in events {
round_trip(&event)?;
}
Ok(())
}
#[test]
fn child_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
let child_workflow_id = WorkflowId::new(uuid::Uuid::from_u128(1));
let events = vec![
Event::ChildWorkflowStarted {
envelope: envelope(16),
child_workflow_id: child_workflow_id.clone(),
workflow_type: String::from("fulfillment"),
input: payload("child-input")?,
package_version: package_version(),
},
Event::ChildWorkflowCompleted {
envelope: envelope(16),
child_workflow_id: child_workflow_id.clone(),
result: payload("child-result")?,
},
Event::ChildWorkflowFailed {
envelope: envelope(17),
child_workflow_id: child_workflow_id.clone(),
error: workflow_error("child failed"),
},
Event::ChildWorkflowCancelled {
envelope: envelope(18),
child_workflow_id,
},
];
for event in events {
round_trip(&event)?;
}
Ok(())
}
#[test]
fn extended_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
let schedule_id = ScheduleId::new(uuid::Uuid::from_u128(2));
let triggered_workflow_id = WorkflowId::new(uuid::Uuid::from_u128(3));
let triggered_run_id = RunId::new(uuid::Uuid::from_u128(4));
let events = vec![
Event::WorkflowContinuedAsNew {
envelope: envelope(19),
input: payload("continued-input")?,
workflow_type: Some(String::from("checkout-v2")),
parent_run_id: RunId::new(uuid::Uuid::from_u128(2)),
},
Event::SearchAttributesUpdated {
envelope: envelope(20),
workflow_id: WorkflowId::new(uuid::Uuid::nil()),
attributes: HashMap::from([(
String::from("customer_id"),
SearchAttributeValue::String(String::from("cust-123")),
)]),
},
Event::ScheduleCreated {
envelope: envelope(20),
schedule_id: schedule_id.clone(),
config: schedule_config("schedule-created")?,
},
Event::ScheduleUpdated {
envelope: envelope(21),
schedule_id: schedule_id.clone(),
config: schedule_config("schedule-updated")?,
},
Event::SchedulePaused {
envelope: envelope(22),
schedule_id: schedule_id.clone(),
},
Event::ScheduleResumed {
envelope: envelope(23),
schedule_id: schedule_id.clone(),
},
Event::ScheduleDeleted {
envelope: envelope(24),
schedule_id: schedule_id.clone(),
},
Event::ScheduleTriggered {
envelope: envelope(25),
schedule_id,
workflow_id: triggered_workflow_id,
run_id: triggered_run_id,
},
];
for event in events {
round_trip(&event)?;
}
Ok(())
}
#[test]
fn workloop_events_round_trip_through_json() -> Result<(), Box<dyn std::error::Error>> {
let events = vec![
Event::CadenceFired {
envelope: envelope(26),
window_seq: 41,
},
Event::IterationClosed {
envelope: envelope(27),
routes: vec![String::from("sweep"), String::from("start")],
health_samples: vec![crate::HealthSample {
invariant: String::from("serving"),
status: crate::HealthStatus::Confirmed,
window_seq: Some(41),
}],
},
Event::LoopRetired {
envelope: envelope(28),
reason: String::from("queue decommissioned; drained on operator signal"),
},
Event::WorkflowHatched {
envelope: envelope(29),
child_workflow_id: crate::hatch_workflow_id("default", "process_task", "task-7")?,
key: String::from("task-7"),
},
Event::InvariantUnconfirmed {
envelope: envelope(30),
invariant: String::from("serving"),
cause: crate::AlarmCause::WindowMissed,
window_seq: Some(42),
last_confirmed_at: Some(recorded_at()),
consecutive_unconfirmed: 4,
},
Event::InvariantUnconfirmed {
envelope: envelope(31),
invariant: String::from("serving"),
cause: crate::AlarmCause::UnconfirmedUnknown,
window_seq: None,
last_confirmed_at: None,
consecutive_unconfirmed: 0,
},
];
for event in events {
round_trip(&event)?;
}
Ok(())
}
fn attribution(transport: WorkerTransport) -> WorkerAttribution {
WorkerAttribution {
identity: String::from("worker-a@host-1"),
task_queue: String::from("billing"),
node: Some(String::from("n1")),
deployment: Some(String::from("billing-workers")),
instance_id: Some(String::from("i-42")),
transport,
}
}
#[test]
fn activity_leased_round_trips_its_worker_names_on_both_transports()
-> Result<(), Box<dyn std::error::Error>> {
for transport in [WorkerTransport::Grpc, WorkerTransport::Liminal] {
let leased = Event::ActivityLeased {
envelope: envelope(8),
activity_id: ActivityId::from_sequence_position(6),
attempt: 3,
worker: attribution(transport),
};
round_trip(&leased)?;
let wire = serde_json::to_value(&leased)?;
assert_eq!(wire["type"], "ActivityLeased");
assert_eq!(wire["data"]["attempt"], 3);
assert_eq!(wire["data"]["worker"]["identity"], "worker-a@host-1");
assert_eq!(wire["data"]["worker"]["task_queue"], "billing");
assert_eq!(wire["data"]["worker"]["node"], "n1");
assert_eq!(wire["data"]["worker"]["deployment"], "billing-workers");
assert_eq!(wire["data"]["worker"]["instance_id"], "i-42");
assert_eq!(
wire["data"]["worker"]["transport"],
serde_json::to_value(transport)?["transport"]
);
match serde_json::from_value::<Event>(wire)? {
Event::ActivityLeased {
activity_id,
attempt,
worker,
..
} => {
assert_eq!(activity_id, ActivityId::from_sequence_position(6));
assert_eq!(attempt, 3);
assert_eq!(worker, attribution(transport));
}
other => return Err(format!("expected ActivityLeased, got {other:?}").into()),
}
}
Ok(())
}
#[test]
fn activity_leased_carries_only_durable_names() -> Result<(), Box<dyn std::error::Error>> {
let wire = serde_json::to_value(Event::ActivityLeased {
envelope: envelope(8),
activity_id: ActivityId::from_sequence_position(6),
attempt: 1,
worker: attribution(WorkerTransport::Grpc),
})?;
let worker = wire["data"]["worker"]
.as_object()
.ok_or("worker must encode as an object")?;
let mut keys: Vec<&str> = worker.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(
keys,
[
"deployment",
"identity",
"instance_id",
"node",
"task_queue",
"transport"
]
);
Ok(())
}
const PRE_LEASE_HISTORY: &str = concat!(
r#"[{"type":"WorkflowStarted","data":{"envelope":{"seq":1,"recorded_at":"2023-11-14T22:13:21Z","workflow_id":"00000000-0000-0000-0000-000000000007"},"workflow_type":"checkout","input":{"content_type":"Json","bytes":[123,34,111,114,100,101,114,34,58,52,50,125]},"run_id":"00000000-0000-0000-0000-000000000001","parent_run_id":null,"parent_workflow_id":null,"package_version":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},"#,
r#"{"type":"ActivityScheduled","data":{"envelope":{"seq":2,"recorded_at":"2023-11-14T22:13:22Z","workflow_id":"00000000-0000-0000-0000-000000000007"},"activity_id":2,"activity_type":"charge-card","input":{"content_type":"Json","bytes":[123,34,97,109,111,117,110,116,34,58,53,125]},"task_queue":"billing","node":"n1"}},"#,
r#"{"type":"ActivityStarted","data":{"envelope":{"seq":3,"recorded_at":"2023-11-14T22:13:23Z","workflow_id":"00000000-0000-0000-0000-000000000007"},"activity_id":2,"attempt":1}},"#,
r#"{"type":"ActivityCompleted","data":{"envelope":{"seq":4,"recorded_at":"2023-11-14T22:13:24Z","workflow_id":"00000000-0000-0000-0000-000000000007"},"activity_id":2,"result":{"content_type":"Json","bytes":[123,34,111,107,34,58,116,114,117,101,125]},"attempt":1}}]"#,
);
#[test]
fn history_without_a_lease_decodes_byte_identically() -> Result<(), Box<dyn std::error::Error>>
{
let decoded: Vec<Event> = serde_json::from_str(PRE_LEASE_HISTORY)?;
assert_eq!(decoded.len(), 4);
assert!(
!decoded
.iter()
.any(|event| matches!(event, Event::ActivityLeased { .. })),
"a pre-lease history must not grow a lease on decode"
);
assert!(matches!(
decoded.get(2),
Some(Event::ActivityStarted { attempt: 1, .. })
));
assert_eq!(serde_json::to_string(&decoded)?, PRE_LEASE_HISTORY);
Ok(())
}
#[test]
fn an_array_row_and_a_base64_row_decode_to_identical_events()
-> Result<(), Box<dyn std::error::Error>> {
use base64::Engine as _;
fn base64_shaped(value: &mut serde_json::Value) -> usize {
match value {
serde_json::Value::Array(items) => items.iter_mut().map(base64_shaped).sum(),
serde_json::Value::Object(object) => {
let is_payload = object
.get("content_type")
.is_some_and(serde_json::Value::is_string)
&& object.get("bytes").is_some_and(serde_json::Value::is_array);
if is_payload {
let bytes: Vec<u8> = object["bytes"]
.as_array()
.into_iter()
.flatten()
.filter_map(|b| b.as_u64().and_then(|b| u8::try_from(b).ok()))
.collect();
object.insert(
"bytes".to_owned(),
serde_json::Value::String(
base64::engine::general_purpose::STANDARD.encode(bytes),
),
);
1
} else {
object.values_mut().map(base64_shaped).sum()
}
}
_ => 0,
}
}
let from_arrays: Vec<Event> = serde_json::from_str(PRE_LEASE_HISTORY)?;
let mut as_base64: serde_json::Value = serde_json::from_str(PRE_LEASE_HISTORY)?;
let rewritten = base64_shaped(&mut as_base64);
assert_eq!(
rewritten, 3,
"all three payloads must have been rewritten to base64"
);
let text = serde_json::to_string(&as_base64)?;
assert!(text.contains(r#""bytes":"eyJvcmRlciI6NDJ9""#), "{text}");
let from_base64: Vec<Event> = serde_json::from_str(&text)?;
assert_eq!(from_base64, from_arrays);
Ok(())
}
}