1use serde::{Deserialize, Serialize};
4
5use crate::{Event, RunId};
6
7#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
13pub enum WorkflowStatus {
14 Running,
16 Completed,
18 Failed,
20 Cancelled,
22 TimedOut,
24 ContinuedAsNew,
26 Paused,
30}
31
32impl WorkflowStatus {
33 #[must_use]
35 pub const fn is_terminal(self) -> bool {
36 match self {
37 Self::Running | Self::Paused => false,
40 Self::Completed
41 | Self::Failed
42 | Self::Cancelled
43 | Self::TimedOut
44 | Self::ContinuedAsNew => true,
45 }
46 }
47}
48
49#[must_use]
57pub fn status_from_events(events: &[Event]) -> WorkflowStatus {
58 events
59 .iter()
60 .rev()
61 .find_map(|event| match event {
62 Event::WorkflowStarted { .. }
69 | Event::WorkflowReopened { .. }
70 | Event::WorkflowResumed { .. } => Some(WorkflowStatus::Running),
71 Event::WorkflowCompleted { .. } => Some(WorkflowStatus::Completed),
72 Event::WorkflowFailed { .. } => Some(WorkflowStatus::Failed),
73 Event::WorkflowCancelled { .. } => Some(WorkflowStatus::Cancelled),
74 Event::WorkflowTimedOut { .. } => Some(WorkflowStatus::TimedOut),
75 Event::WorkflowContinuedAsNew { .. } => Some(WorkflowStatus::ContinuedAsNew),
76 Event::WorkflowPaused { .. } => Some(WorkflowStatus::Paused),
79 Event::SearchAttributesUpdated { .. }
80 | Event::ActivityScheduled { .. }
81 | Event::ActivityStarted { .. }
82 | Event::ActivityLeased { .. }
85 | Event::ActivityAdoptionOffered { .. }
86 | Event::ActivityCompleted { .. }
87 | Event::ActivityFailed { .. }
88 | Event::ActivityAdvisoryExhausted { .. }
91 | Event::ActivityFallbackRouted { .. }
93 | Event::ActivityCancelled { .. }
94 | Event::TimerStarted { .. }
95 | Event::TimerFired { .. }
96 | Event::TimerCancelled { .. }
97 | Event::WithTimeoutCompleted { .. }
98 | Event::SignalReceived { .. }
99 | Event::SignalSent { .. }
100 | Event::ChildWorkflowStarted { .. }
101 | Event::ChildWorkflowCompleted { .. }
102 | Event::ChildWorkflowFailed { .. }
103 | Event::ChildWorkflowCancelled { .. }
104 | Event::ScheduleCreated { .. }
105 | Event::ScheduleUpdated { .. }
106 | Event::SchedulePaused { .. }
107 | Event::ScheduleResumed { .. }
108 | Event::ScheduleDeleted { .. }
109 | Event::ScheduleTriggered { .. }
110 | Event::CadenceFired { .. }
115 | Event::IterationClosed { .. }
116 | Event::LoopRetired { .. }
117 | Event::WorkflowHatched { .. }
118 | Event::InvariantUnconfirmed { .. } => None,
119 })
120 .unwrap_or(WorkflowStatus::Running)
121}
122
123#[must_use]
134pub fn current_lease_terminal(events: &[Event]) -> Option<&Event> {
135 events
136 .iter()
137 .rev()
138 .find_map(|event| match event {
139 Event::WorkflowCompleted { .. }
140 | Event::WorkflowFailed { .. }
141 | Event::WorkflowCancelled { .. }
142 | Event::WorkflowTimedOut { .. }
143 | Event::WorkflowContinuedAsNew { .. } => Some(Some(event)),
144 Event::WorkflowStarted { .. } | Event::WorkflowReopened { .. } => Some(None),
146 Event::WorkflowPaused { .. }
151 | Event::WorkflowResumed { .. }
152 | Event::SearchAttributesUpdated { .. }
153 | Event::ActivityScheduled { .. }
154 | Event::ActivityStarted { .. }
155 | Event::ActivityLeased { .. }
158 | Event::ActivityAdoptionOffered { .. }
159 | Event::ActivityCompleted { .. }
160 | Event::ActivityFailed { .. }
161 | Event::ActivityAdvisoryExhausted { .. }
164 | Event::ActivityFallbackRouted { .. }
166 | Event::ActivityCancelled { .. }
167 | Event::TimerStarted { .. }
168 | Event::TimerFired { .. }
169 | Event::TimerCancelled { .. }
170 | Event::WithTimeoutCompleted { .. }
171 | Event::SignalReceived { .. }
172 | Event::SignalSent { .. }
173 | Event::ChildWorkflowStarted { .. }
174 | Event::ChildWorkflowCompleted { .. }
175 | Event::ChildWorkflowFailed { .. }
176 | Event::ChildWorkflowCancelled { .. }
177 | Event::ScheduleCreated { .. }
178 | Event::ScheduleUpdated { .. }
179 | Event::SchedulePaused { .. }
180 | Event::ScheduleResumed { .. }
181 | Event::ScheduleDeleted { .. }
182 | Event::ScheduleTriggered { .. }
183 | Event::CadenceFired { .. }
187 | Event::IterationClosed { .. }
188 | Event::LoopRetired { .. }
189 | Event::WorkflowHatched { .. }
190 | Event::InvariantUnconfirmed { .. } => None,
191 })
192 .flatten()
193}
194
195#[must_use]
203pub fn run_segment<'a>(events: &'a [Event], run_id: &RunId) -> &'a [Event] {
204 let Some(start) = events.iter().position(
205 |event| matches!(event, Event::WorkflowStarted { run_id: id, .. } if id == run_id),
206 ) else {
207 return &[];
208 };
209 let end = events[start + 1..]
210 .iter()
211 .position(|event| matches!(event, Event::WorkflowStarted { .. }))
212 .map_or(events.len(), |offset| start + 1 + offset);
213 &events[start..end]
214}
215
216#[cfg(test)]
217mod tests {
218 use std::collections::HashMap;
219
220 use chrono::{DateTime, Utc};
221 use serde_json::json;
222
223 use super::{WorkflowStatus, current_lease_terminal, run_segment, status_from_events};
224 use crate::{
225 ActivityId, Event, EventEnvelope, Payload, RunId, ScheduleId, SearchAttributeValue,
226 WorkerAttribution, WorkerTransport, WorkflowError, WorkflowId,
227 };
228
229 fn recorded_at(offset: i64) -> DateTime<Utc> {
230 DateTime::from_timestamp(1_700_000_000 + offset, 0).unwrap_or_default()
231 }
232
233 fn envelope(seq: u64) -> EventEnvelope {
234 EventEnvelope {
235 seq,
236 recorded_at: recorded_at(i64::try_from(seq).unwrap_or(0)),
237 workflow_id: WorkflowId::new(uuid::Uuid::nil()),
238 }
239 }
240
241 fn payload(label: &str) -> Result<Payload, crate::PayloadError> {
242 Payload::from_json(&json!({ "label": label }))
243 }
244
245 fn workflow_started(seq: u64) -> Result<Event, crate::PayloadError> {
246 Ok(Event::WorkflowStarted {
247 envelope: envelope(seq),
248 workflow_type: String::from("checkout"),
249 input: payload("input")?,
250 run_id: RunId::new(uuid::Uuid::from_u128(1)),
251 parent_run_id: None,
252 parent_workflow_id: None,
253 package_version: crate::PackageVersion::new("a".repeat(64)),
254 })
255 }
256
257 fn workflow_error(message: &str) -> WorkflowError {
258 WorkflowError {
259 message: String::from(message),
260 details: None,
261 }
262 }
263
264 #[test]
265 fn empty_history_projects_to_running() {
266 assert_eq!(status_from_events(&[]), WorkflowStatus::Running);
267 }
268
269 #[test]
270 fn replacement_start_projects_continue_as_new_chain_running() -> Result<(), crate::PayloadError>
271 {
272 let parent_run_id = RunId::new(uuid::Uuid::from_u128(7));
273 let events = vec![
274 workflow_started(1)?,
275 Event::WorkflowContinuedAsNew {
276 envelope: envelope(2),
277 input: payload("replacement")?,
278 workflow_type: None,
279 parent_run_id: parent_run_id.clone(),
280 },
281 Event::WorkflowStarted {
282 envelope: envelope(3),
283 workflow_type: String::from("checkout"),
284 input: payload("replacement")?,
285 run_id: RunId::new(uuid::Uuid::from_u128(1)),
286 parent_run_id: Some(parent_run_id),
287 parent_workflow_id: None,
288 package_version: crate::PackageVersion::new("a".repeat(64)),
289 },
290 ];
291
292 assert_eq!(status_from_events(&events), WorkflowStatus::Running);
293 Ok(())
294 }
295
296 #[test]
297 fn completed_terminal_event_projects_to_completed() -> Result<(), Box<dyn std::error::Error>> {
298 let events = vec![
299 workflow_started(1)?,
300 Event::WorkflowCompleted {
301 envelope: envelope(2),
302 result: payload("result")?,
303 },
304 ];
305
306 assert_eq!(status_from_events(&events), WorkflowStatus::Completed);
307 Ok(())
308 }
309
310 #[test]
311 fn failed_terminal_event_projects_to_failed() -> Result<(), Box<dyn std::error::Error>> {
312 let events = vec![
313 workflow_started(1)?,
314 Event::WorkflowFailed {
315 envelope: envelope(2),
316 error: workflow_error("failed"),
317 },
318 ];
319
320 assert_eq!(status_from_events(&events), WorkflowStatus::Failed);
321 Ok(())
322 }
323
324 #[test]
325 fn cancelled_terminal_event_projects_to_cancelled() -> Result<(), Box<dyn std::error::Error>> {
326 let events = vec![
327 workflow_started(1)?,
328 Event::WorkflowCancelled {
329 envelope: envelope(2),
330 reason: String::from("caller requested cancellation"),
331 },
332 ];
333
334 assert_eq!(status_from_events(&events), WorkflowStatus::Cancelled);
335 Ok(())
336 }
337
338 #[test]
339 fn timed_out_terminal_event_projects_to_timed_out() -> Result<(), Box<dyn std::error::Error>> {
340 let events = vec![
341 workflow_started(1)?,
342 Event::WorkflowTimedOut {
343 envelope: envelope(2),
344 timeout: String::from("execution"),
345 },
346 ];
347
348 assert_eq!(status_from_events(&events), WorkflowStatus::TimedOut);
349 Ok(())
350 }
351
352 #[test]
353 fn continued_as_new_projects_status() -> Result<(), Box<dyn std::error::Error>> {
354 let events = vec![
355 workflow_started(1)?,
356 Event::WorkflowContinuedAsNew {
357 envelope: envelope(2),
358 input: payload("continued-input")?,
359 workflow_type: Some(String::from("checkout-v2")),
360 parent_run_id: RunId::new(uuid::Uuid::from_u128(2)),
361 },
362 ];
363
364 assert_eq!(status_from_events(&events), WorkflowStatus::ContinuedAsNew);
365 Ok(())
366 }
367
368 #[test]
369 fn workflow_status_terminality_classifies_running_and_terminal_statuses() {
370 assert!(!WorkflowStatus::Running.is_terminal());
371 assert!(WorkflowStatus::Completed.is_terminal());
372 assert!(WorkflowStatus::Failed.is_terminal());
373 assert!(WorkflowStatus::Cancelled.is_terminal());
374 assert!(WorkflowStatus::TimedOut.is_terminal());
375 assert!(WorkflowStatus::ContinuedAsNew.is_terminal());
376 }
377
378 #[test]
379 fn started_then_continued_as_new_projects_status() -> Result<(), Box<dyn std::error::Error>> {
380 let events = vec![
381 workflow_started(1)?,
382 Event::WorkflowContinuedAsNew {
383 envelope: envelope(2),
384 input: payload("continued-input")?,
385 workflow_type: None,
386 parent_run_id: RunId::new(uuid::Uuid::from_u128(3)),
387 },
388 ];
389
390 assert_eq!(status_from_events(&events), WorkflowStatus::ContinuedAsNew);
391 Ok(())
392 }
393
394 #[test]
395 fn non_terminal_history_projects_to_running() -> Result<(), Box<dyn std::error::Error>> {
396 let events = vec![
397 workflow_started(1)?,
398 Event::SearchAttributesUpdated {
399 envelope: envelope(2),
400 workflow_id: WorkflowId::new(uuid::Uuid::nil()),
401 attributes: HashMap::from([(
402 String::from("customer_id"),
403 SearchAttributeValue::String(String::from("customer-123")),
404 )]),
405 },
406 Event::ActivityScheduled {
407 envelope: envelope(3),
408 activity_id: ActivityId::from_sequence_position(3),
409 activity_type: String::from("charge-card"),
410 input: payload("activity-input")?,
411 task_queue: String::from("default"),
412 node: None,
413 },
414 ];
415
416 assert_eq!(status_from_events(&events), WorkflowStatus::Running);
417 Ok(())
418 }
419
420 #[test]
421 fn schedule_events_do_not_change_workflow_status() -> Result<(), Box<dyn std::error::Error>> {
422 let events = vec![
423 workflow_started(1)?,
424 Event::SchedulePaused {
425 envelope: envelope(2),
426 schedule_id: ScheduleId::new(uuid::Uuid::from_u128(2)),
427 },
428 ];
429
430 assert_eq!(status_from_events(&events), WorkflowStatus::Running);
431 Ok(())
432 }
433
434 #[test]
435 fn projection_is_deterministic() -> Result<(), Box<dyn std::error::Error>> {
436 let events = vec![
437 workflow_started(1)?,
438 Event::WorkflowCompleted {
439 envelope: envelope(2),
440 result: payload("result")?,
441 },
442 ];
443
444 let first = status_from_events(&events);
445 let second = status_from_events(&events);
446
447 assert_eq!(first, second);
448 Ok(())
449 }
450
451 #[test]
452 fn last_terminal_lifecycle_event_determines_status() -> Result<(), Box<dyn std::error::Error>> {
453 let events = vec![
454 workflow_started(1)?,
455 Event::WorkflowCompleted {
456 envelope: envelope(2),
457 result: payload("result")?,
458 },
459 Event::WorkflowTimedOut {
460 envelope: envelope(3),
461 timeout: String::from("execution"),
462 },
463 ];
464
465 assert_eq!(status_from_events(&events), WorkflowStatus::TimedOut);
466 Ok(())
467 }
468
469 fn run_id() -> RunId {
470 RunId::new(uuid::Uuid::from_u128(1))
471 }
472
473 fn workflow_reopened(seq: u64, reopened: Vec<ActivityId>) -> Event {
474 Event::WorkflowReopened {
475 envelope: envelope(seq),
476 run_id: run_id(),
477 reopened,
478 }
479 }
480
481 #[test]
482 fn reopen_after_failure_projects_running() -> Result<(), Box<dyn std::error::Error>> {
483 let events = vec![
484 workflow_started(1)?,
485 Event::WorkflowFailed {
486 envelope: envelope(2),
487 error: workflow_error("transient"),
488 },
489 workflow_reopened(3, vec![ActivityId::from_sequence_position(2)]),
490 ];
491
492 assert_eq!(status_from_events(&events), WorkflowStatus::Running);
493 Ok(())
494 }
495
496 #[test]
497 fn reopen_then_new_terminal_projects_that_terminal() -> Result<(), Box<dyn std::error::Error>> {
498 let events = vec![
499 workflow_started(1)?,
500 Event::WorkflowFailed {
501 envelope: envelope(2),
502 error: workflow_error("transient"),
503 },
504 workflow_reopened(3, vec![ActivityId::from_sequence_position(2)]),
505 Event::WorkflowCompleted {
506 envelope: envelope(4),
507 result: payload("result")?,
508 },
509 ];
510
511 assert_eq!(status_from_events(&events), WorkflowStatus::Completed);
512 Ok(())
513 }
514
515 #[test]
516 fn current_lease_terminal_is_none_for_running_and_reopened()
517 -> Result<(), Box<dyn std::error::Error>> {
518 let running = vec![workflow_started(1)?];
519 assert!(current_lease_terminal(&running).is_none());
520
521 let failed = vec![
522 workflow_started(1)?,
523 Event::WorkflowFailed {
524 envelope: envelope(2),
525 error: workflow_error("boom"),
526 },
527 ];
528 assert!(matches!(
529 current_lease_terminal(&failed),
530 Some(Event::WorkflowFailed { .. })
531 ));
532
533 let mut reopened = failed.clone();
534 reopened.push(workflow_reopened(
535 3,
536 vec![ActivityId::from_sequence_position(2)],
537 ));
538 assert!(
539 current_lease_terminal(&reopened).is_none(),
540 "a reopened run has no current-lease terminal"
541 );
542 Ok(())
543 }
544
545 #[test]
546 fn current_lease_terminal_returns_terminal_after_reopen_and_retermination()
547 -> Result<(), Box<dyn std::error::Error>> {
548 let events = vec![
549 workflow_started(1)?,
550 Event::WorkflowFailed {
551 envelope: envelope(2),
552 error: workflow_error("boom"),
553 },
554 workflow_reopened(3, vec![ActivityId::from_sequence_position(2)]),
555 Event::WorkflowCompleted {
556 envelope: envelope(4),
557 result: payload("result")?,
558 },
559 ];
560
561 assert!(matches!(
562 current_lease_terminal(&events),
563 Some(Event::WorkflowCompleted { .. })
564 ));
565 Ok(())
566 }
567
568 fn workflow_paused(seq: u64) -> Event {
569 Event::WorkflowPaused {
570 envelope: envelope(seq),
571 run_id: run_id(),
572 reason: None,
573 operator: None,
574 }
575 }
576
577 fn workflow_resumed(seq: u64) -> Event {
578 Event::WorkflowResumed {
579 envelope: envelope(seq),
580 run_id: run_id(),
581 operator: None,
582 }
583 }
584
585 #[test]
586 fn pause_projects_paused_and_is_non_terminal() -> Result<(), Box<dyn std::error::Error>> {
587 let events = vec![workflow_started(1)?, workflow_paused(2)];
588 assert_eq!(status_from_events(&events), WorkflowStatus::Paused);
589 assert!(!WorkflowStatus::Paused.is_terminal());
590 assert!(current_lease_terminal(&events).is_none());
593 Ok(())
594 }
595
596 #[test]
597 fn resume_after_pause_projects_running() -> Result<(), Box<dyn std::error::Error>> {
598 let events = vec![
599 workflow_started(1)?,
600 workflow_paused(2),
601 workflow_resumed(3),
602 ];
603 assert_eq!(status_from_events(&events), WorkflowStatus::Running);
604 assert!(current_lease_terminal(&events).is_none());
605 Ok(())
606 }
607
608 #[test]
609 fn complete_after_resume_projects_completed() -> Result<(), Box<dyn std::error::Error>> {
610 let events = vec![
611 workflow_started(1)?,
612 workflow_paused(2),
613 workflow_resumed(3),
614 Event::WorkflowCompleted {
615 envelope: envelope(4),
616 result: payload("result")?,
617 },
618 ];
619 assert_eq!(status_from_events(&events), WorkflowStatus::Completed);
620 assert!(matches!(
621 current_lease_terminal(&events),
622 Some(Event::WorkflowCompleted { .. })
623 ));
624 Ok(())
625 }
626
627 #[test]
628 fn complete_while_paused_still_projects_completed() -> Result<(), Box<dyn std::error::Error>> {
629 let events = vec![
632 workflow_started(1)?,
633 workflow_paused(2),
634 Event::WorkflowCompleted {
635 envelope: envelope(3),
636 result: payload("result")?,
637 },
638 ];
639 assert_eq!(status_from_events(&events), WorkflowStatus::Completed);
640 Ok(())
641 }
642
643 #[test]
644 fn run_segment_scopes_to_the_named_run() -> Result<(), Box<dyn std::error::Error>> {
645 let first_run = RunId::new(uuid::Uuid::from_u128(10));
646 let second_run = RunId::new(uuid::Uuid::from_u128(20));
647 let events = vec![
648 Event::WorkflowStarted {
649 envelope: envelope(1),
650 workflow_type: String::from("checkout"),
651 input: payload("input")?,
652 run_id: first_run.clone(),
653 parent_run_id: None,
654 parent_workflow_id: None,
655 package_version: crate::PackageVersion::new("a".repeat(64)),
656 },
657 Event::WorkflowContinuedAsNew {
658 envelope: envelope(2),
659 input: payload("again")?,
660 workflow_type: None,
661 parent_run_id: first_run.clone(),
662 },
663 Event::WorkflowStarted {
664 envelope: envelope(3),
665 workflow_type: String::from("checkout"),
666 input: payload("input")?,
667 run_id: second_run.clone(),
668 parent_run_id: Some(first_run.clone()),
669 parent_workflow_id: None,
670 package_version: crate::PackageVersion::new("a".repeat(64)),
671 },
672 Event::WorkflowCompleted {
673 envelope: envelope(4),
674 result: payload("result")?,
675 },
676 ];
677
678 let first = run_segment(&events, &first_run);
679 assert_eq!(first.len(), 2, "first run spans its start through its CAN");
680 assert!(matches!(
681 current_lease_terminal(first),
682 Some(Event::WorkflowContinuedAsNew { .. })
683 ));
684
685 let second = run_segment(&events, &second_run);
686 assert_eq!(
687 second.len(),
688 2,
689 "second run spans its start through completion"
690 );
691 assert!(matches!(
692 current_lease_terminal(second),
693 Some(Event::WorkflowCompleted { .. })
694 ));
695
696 assert!(
697 run_segment(&events, &RunId::new(uuid::Uuid::from_u128(99))).is_empty(),
698 "absent run yields an empty segment"
699 );
700 Ok(())
701 }
702
703 #[test]
707 fn a_lease_without_a_completion_still_projects_running()
708 -> Result<(), Box<dyn std::error::Error>> {
709 let events = vec![
710 workflow_started(1)?,
711 Event::ActivityScheduled {
712 envelope: envelope(2),
713 activity_id: ActivityId::from_sequence_position(2),
714 activity_type: String::from("charge-card"),
715 input: payload("activity-input")?,
716 task_queue: String::from("billing"),
717 node: None,
718 },
719 Event::ActivityStarted {
720 envelope: envelope(3),
721 activity_id: ActivityId::from_sequence_position(2),
722 attempt: 1,
723 },
724 Event::ActivityLeased {
725 envelope: envelope(4),
726 activity_id: ActivityId::from_sequence_position(2),
727 attempt: 1,
728 worker: WorkerAttribution {
729 identity: String::from("worker-a@host-1"),
730 task_queue: String::from("billing"),
731 node: None,
732 deployment: None,
733 instance_id: None,
734 transport: WorkerTransport::Grpc,
735 },
736 },
737 ];
738 assert_eq!(status_from_events(&events), WorkflowStatus::Running);
739 assert!(current_lease_terminal(&events).is_none());
740 Ok(())
741 }
742}