1#![doc = include_str!("../README.md")]
2
3use std::{
4 any::{Any, TypeId},
5 collections::{BTreeMap, HashMap},
6 future::Future,
7 pin::Pin,
8 sync::{
9 atomic::{AtomicBool, Ordering},
10 Arc, Mutex, OnceLock,
11 },
12 task::{Context as TaskContext, Poll},
13 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
14};
15
16use apache_avro::{from_avro_datum, from_value, to_avro_datum, to_value, Schema};
17use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
18use futures_util::{future::OptionFuture, task::noop_waker_ref};
19use serde::{de::DeserializeOwned, Deserialize, Serialize};
20pub use serde_json::{json, Value};
21use thiserror::Error;
22pub use uuid::Uuid;
23
24pub const WORKER_PROTOCOL_VERSION: &str = "1.2";
25pub const CONTROL_PLANE_VERSION: &str = "2";
26pub const DEFAULT_CODEC: &str = "avro";
27pub const JSON_CODEC: &str = "json";
28pub const SDK_VERSION: &str = concat!("durable-workflow-rust/", env!("CARGO_PKG_VERSION"));
29pub const QUERY_TASKS_CAPABILITY: &str = "query_tasks";
31pub const QUERY_TASK_MINIMUM_WORKER_PROTOCOL_VERSION: &str = "1.8";
33
34const MAX_LONG_POLL_TIMEOUT_SECONDS: u64 = 60;
35const WORKFLOW_TASK_WAITING_FOR_HISTORY_MESSAGE: &str =
36 "Workflow task waiting for scheduled history.";
37const WORKFLOW_TASK_WAITING_FOR_HISTORY_TYPE: &str = "WorkflowTaskWaitingForHistory";
38
39const QUERY_TASK_FINAL_REJECTION_REASONS: &[&str] = &[
40 "lease_expired",
41 "query_task_not_found",
42 "query_task_not_leased",
43 "query_task_timed_out",
44];
45
46const AVRO_PAYLOAD_SCHEMA_JSON: &str = r#"{"type":"record","name":"Payload","namespace":"durable_workflow","fields":[{"name":"json","type":"string"},{"name":"version","type":"int","default":1}]}"#;
47const AVRO_PAYLOAD_VERSION: i32 = 1;
48
49static AVRO_PAYLOAD_SCHEMA: OnceLock<std::result::Result<Schema, String>> = OnceLock::new();
50
51#[derive(Clone, Copy)]
52enum RequestProtocol {
53 ControlPlane,
54 Worker(&'static str),
55}
56
57impl RequestProtocol {
58 fn is_worker(self) -> bool {
59 matches!(self, Self::Worker(_))
60 }
61}
62
63pub type Result<T> = std::result::Result<T, Error>;
64
65#[derive(Debug, Error)]
66pub enum Error {
67 #[error("transport error: {0}")]
68 Transport(#[from] reqwest::Error),
69 #[error("json error: {0}")]
70 Json(#[from] serde_json::Error),
71 #[error("http {status}: {body}")]
72 Http {
73 status: reqwest::StatusCode,
74 body: String,
75 },
76 #[error("codec error: {0}")]
77 Codec(String),
78 #[error(transparent)]
79 QueryFailed(QueryFailure),
80 #[error(transparent)]
81 Protocol(ProtocolFailure),
82 #[error(transparent)]
83 NonDeterministicReplay(ReplayFailure),
84 #[error(transparent)]
85 ChildWorkflowFailed(ChildWorkflowFailure),
86 #[error(transparent)]
87 ActivityFailed(ActivityFailure),
88 #[error(transparent)]
89 WorkflowCommandRejected(WorkflowCommandRejection),
90 #[error(transparent)]
91 WorkflowFailed(WorkflowTerminalOutcome),
92 #[error(transparent)]
93 WorkflowCancelled(WorkflowTerminalOutcome),
94 #[error(transparent)]
95 WorkflowTerminated(WorkflowTerminalOutcome),
96 #[error(transparent)]
97 WorkflowTimedOut(WorkflowTerminalOutcome),
98 #[error(transparent)]
99 ActivityTaskRejected(ActivityTaskRejection),
100 #[error("workflow handler {0:?} is not registered")]
101 WorkflowNotRegistered(String),
102 #[error("activity handler {0:?} is not registered")]
103 ActivityNotRegistered(String),
104 #[error("workflow future yielded without emitting a durable command")]
105 WorkflowYieldedWithoutCommand,
106 #[error("workflow state lock is poisoned")]
107 WorkflowStatePoisoned,
108 #[error("timer duration is too large for the worker protocol")]
109 TimerDurationOverflow,
110 #[error("operation timed out")]
111 Timeout,
112 #[error("worker loop error: {0}")]
113 WorkerLoop(String),
114 #[error("invalid child workflow options: {0}")]
115 InvalidChildWorkflowOptions(String),
116 #[error(transparent)]
117 InvalidActivityOptions(ActivityOptionsError),
118 #[error(transparent)]
119 InvalidContinueAsNewOptions(#[from] ContinueAsNewOptionsError),
120 #[doc(hidden)]
121 #[error("workflow requested continue as new")]
122 ContinueAsNew(ContinueAsNewRequest),
123}
124
125#[derive(Clone, Copy, Debug, PartialEq, Eq)]
127pub enum WorkflowCommandKind {
128 Cancel,
129 Terminate,
130}
131
132impl WorkflowCommandKind {
133 fn as_str(self) -> &'static str {
134 match self {
135 Self::Cancel => "cancel",
136 Self::Terminate => "terminate",
137 }
138 }
139}
140
141#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
143pub struct WorkflowCommandOptions {
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub reason: Option<String>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub request_id: Option<String>,
148}
149
150#[derive(Clone, Debug, PartialEq, Eq)]
157pub struct WorkflowStartOptions {
158 pub execution_timeout_seconds: u64,
159 pub run_timeout_seconds: u64,
160}
161
162impl Default for WorkflowStartOptions {
163 fn default() -> Self {
164 Self {
165 execution_timeout_seconds: 3600,
166 run_timeout_seconds: 600,
167 }
168 }
169}
170
171impl WorkflowStartOptions {
172 pub fn new() -> Self {
173 Self::default()
174 }
175
176 pub fn execution_timeout_seconds(mut self, seconds: u64) -> Self {
177 self.execution_timeout_seconds = seconds;
178 self
179 }
180
181 pub fn run_timeout_seconds(mut self, seconds: u64) -> Self {
182 self.run_timeout_seconds = seconds;
183 self
184 }
185
186 fn validate(&self) -> Result<()> {
187 if self.execution_timeout_seconds == 0 {
188 return Err(Error::Codec(
189 "execution_timeout_seconds must be at least 1".to_string(),
190 ));
191 }
192 if self.run_timeout_seconds == 0 {
193 return Err(Error::Codec(
194 "run_timeout_seconds must be at least 1".to_string(),
195 ));
196 }
197 if self.run_timeout_seconds > self.execution_timeout_seconds {
198 return Err(Error::Codec(
199 "run_timeout_seconds cannot exceed execution_timeout_seconds".to_string(),
200 ));
201 }
202
203 Ok(())
204 }
205}
206
207#[derive(Clone, Debug, Default, PartialEq, Eq)]
212pub struct ContinueAsNewOptions {
213 pub workflow_type: Option<String>,
214 pub task_queue: Option<String>,
215}
216
217impl ContinueAsNewOptions {
218 pub fn new() -> Self {
219 Self::default()
220 }
221
222 pub fn workflow_type(mut self, workflow_type: impl Into<String>) -> Self {
223 self.workflow_type = Some(workflow_type.into());
224 self
225 }
226
227 pub fn task_queue(mut self, task_queue: impl Into<String>) -> Self {
228 self.task_queue = Some(task_queue.into());
229 self
230 }
231
232 fn validate(&self) -> std::result::Result<(), ContinueAsNewOptionsError> {
233 for (field, value) in [
234 ("workflow_type", self.workflow_type.as_deref()),
235 ("task_queue", self.task_queue.as_deref()),
236 ] {
237 if value.is_some_and(|value| value.trim().is_empty()) {
238 return Err(ContinueAsNewOptionsError {
239 field,
240 message: format!("{field} must not be empty"),
241 });
242 }
243 }
244 Ok(())
245 }
246}
247
248#[derive(Clone, Debug, Error, PartialEq, Eq)]
250#[error("invalid continue-as-new option {field}: {message}")]
251pub struct ContinueAsNewOptionsError {
252 pub field: &'static str,
253 pub message: String,
254}
255
256#[derive(Clone, Debug, Default, PartialEq, Eq)]
258pub struct WorkflowHistoryBudget {
259 pub event_count: u64,
260 pub size_bytes: Option<u64>,
261 pub continue_as_new_recommended: bool,
262 pub pressure: Option<String>,
263}
264
265#[doc(hidden)]
266#[derive(Clone, Debug)]
267pub struct ContinueAsNewRequest {
268 arguments: Value,
269 options: ContinueAsNewOptions,
270}
271
272impl WorkflowCommandOptions {
273 pub fn new() -> Self {
274 Self::default()
275 }
276
277 pub fn reason(mut self, reason: impl Into<String>) -> Self {
278 self.reason = Some(reason.into());
279 self
280 }
281
282 pub fn request_id(mut self, request_id: impl Into<String>) -> Self {
283 self.request_id = Some(request_id.into());
284 self
285 }
286}
287
288#[derive(Clone, Debug, PartialEq)]
290pub struct WorkflowCommandResult {
291 pub command: WorkflowCommandKind,
292 pub workflow_id: String,
293 pub run_id: Option<String>,
294 pub outcome: Option<String>,
295 pub reason: Option<String>,
296 pub command_status: Option<String>,
297 pub raw: Value,
298}
299
300#[derive(Clone, Debug, Error)]
302#[error("workflow {command:?} rejected ({reason}, HTTP {status}): {message}")]
303pub struct WorkflowCommandRejection {
304 pub command: WorkflowCommandKind,
305 pub status: u16,
306 pub reason: String,
307 pub message: String,
308 pub workflow_id: String,
309 pub run_id: Option<String>,
310 pub target_scope: Option<String>,
311 pub body: Value,
312}
313
314#[derive(Clone, Copy, Debug, PartialEq, Eq)]
316pub enum WorkflowTerminalKind {
317 Failed,
318 Cancelled,
319 Terminated,
320 TimedOut,
321}
322
323#[derive(Clone, Debug, Error)]
329#[error("workflow {workflow_id} run {run_id:?} ended as {kind:?} ({reason})")]
330pub struct WorkflowTerminalOutcome {
331 pub kind: WorkflowTerminalKind,
332 pub workflow_id: String,
333 pub run_id: Option<String>,
334 pub reason: String,
335 pub failure_category: Option<String>,
336 pub failure_id: Option<String>,
337 pub exception_type: Option<String>,
338 pub exception_class: Option<String>,
339 pub non_retryable: Option<bool>,
340 pub message: Option<String>,
341 pub exception: Option<Value>,
342 pub raw: Value,
343}
344
345#[derive(Clone, Debug, Error)]
347#[error("activity task {operation} rejected ({reason}, HTTP {status})")]
348pub struct ActivityTaskRejection {
349 pub operation: String,
350 pub status: u16,
351 pub reason: String,
352 pub task_id: String,
353 pub activity_attempt_id: String,
354 pub cancel_requested: bool,
355 pub can_continue: Option<bool>,
356 pub run_closed_reason: Option<String>,
357 pub body: Value,
358}
359
360#[derive(Clone, Copy, Debug, PartialEq, Eq)]
362pub enum ActivityOptionsErrorKind {
363 EmptyTaskQueue,
364 EmptyRetryPolicy,
365 InvalidMaxAttempts,
366 BackoffWithoutRetryBudget,
367 TooManyBackoffIntervals,
368 InvalidBackoffCoefficient,
369 BackoffGenerationTooLarge,
370 BackoffOverflow,
371 EmptyNonRetryableErrorType,
372 TimeoutNotPositive,
373 TimeoutOverflow,
374 TimeoutOrder,
375}
376
377#[derive(Clone, Debug, Error, PartialEq, Eq)]
379#[error("invalid activity options ({kind:?}, {field:?}): {message}")]
380pub struct ActivityOptionsError {
381 pub kind: ActivityOptionsErrorKind,
382 pub field: Option<&'static str>,
383 pub message: String,
384}
385
386impl ActivityOptionsError {
387 fn new(
388 kind: ActivityOptionsErrorKind,
389 field: Option<&'static str>,
390 message: impl Into<String>,
391 ) -> Self {
392 Self {
393 kind,
394 field,
395 message: message.into(),
396 }
397 }
398}
399
400#[derive(Clone, Copy, Debug, PartialEq, Eq)]
402pub enum ActivityFailureKind {
403 Failed,
404 Cancelled,
405 TimedOut,
406}
407
408#[derive(Clone, Debug, Error)]
413#[error("activity failed ({reason}): {message}")]
414pub struct ActivityFailure {
415 pub kind: ActivityFailureKind,
416 pub reason: String,
417 pub message: String,
418 pub activity_execution_id: Option<String>,
419 pub activity_attempt_id: Option<String>,
420 pub activity_type: Option<String>,
421 pub activity_class: Option<String>,
422 pub attempt_number: Option<u64>,
423 pub failure_id: Option<String>,
424 pub failure_category: Option<String>,
425 pub timeout_kind: Option<String>,
426 pub non_retryable: bool,
427 pub exception_type: Option<String>,
428 pub exception_class: Option<String>,
429 pub code: Option<Value>,
430 pub exception: Option<Value>,
431}
432
433#[derive(Clone, Copy, Debug, PartialEq, Eq)]
435pub enum ChildWorkflowFailureKind {
436 Failed,
437 Cancelled,
438 Terminated,
439}
440
441#[derive(Clone, Debug, Error)]
447#[error("child workflow failed ({reason}): {message}")]
448pub struct ChildWorkflowFailure {
449 pub kind: ChildWorkflowFailureKind,
450 pub reason: String,
451 pub message: String,
452 pub parent_workflow_id: Option<String>,
453 pub parent_workflow_run_id: Option<String>,
454 pub child_workflow_id: Option<String>,
455 pub child_workflow_run_id: Option<String>,
456 pub child_workflow_type: Option<String>,
457 pub failure_id: Option<String>,
458 pub failure_category: Option<String>,
459 pub exception_type: Option<String>,
460 pub exception_class: Option<String>,
461 pub non_retryable: bool,
462 pub code: Option<Value>,
463 pub exception: Option<Value>,
464}
465
466#[derive(Clone, Debug, PartialEq, Eq)]
468pub struct WorkflowIdentity {
469 pub workflow_id: Option<String>,
470 pub run_id: Option<String>,
471}
472
473#[derive(Clone, Debug, PartialEq)]
475pub struct ChildWorkflowResult {
476 pub parent: WorkflowIdentity,
477 pub child: WorkflowIdentity,
478 pub child_workflow_type: Option<String>,
479 pub result: Value,
480}
481
482#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
484pub enum ParentClosePolicy {
485 #[default]
486 Abandon,
487 RequestCancel,
488 Terminate,
489}
490
491impl ParentClosePolicy {
492 fn as_str(self) -> &'static str {
493 match self {
494 Self::Abandon => "abandon",
495 Self::RequestCancel => "request_cancel",
496 Self::Terminate => "terminate",
497 }
498 }
499}
500
501#[derive(Clone, Debug, Default, PartialEq, Eq)]
503pub struct ChildWorkflowRetryPolicy {
504 pub max_attempts: Option<u32>,
505 pub backoff_seconds: Vec<u64>,
506 pub non_retryable_error_types: Vec<String>,
507}
508
509#[derive(Clone, Debug, PartialEq, Eq)]
513pub struct ChildWorkflowOptions {
514 pub task_queue: String,
515 pub parent_close_policy: ParentClosePolicy,
516 pub retry_policy: Option<ChildWorkflowRetryPolicy>,
517 pub execution_timeout_seconds: Option<u64>,
518 pub run_timeout_seconds: Option<u64>,
519}
520
521impl ChildWorkflowOptions {
522 pub fn new(task_queue: impl Into<String>) -> Self {
523 Self {
524 task_queue: task_queue.into(),
525 parent_close_policy: ParentClosePolicy::Abandon,
526 retry_policy: None,
527 execution_timeout_seconds: None,
528 run_timeout_seconds: None,
529 }
530 }
531
532 pub fn parent_close_policy(mut self, policy: ParentClosePolicy) -> Self {
533 self.parent_close_policy = policy;
534 self
535 }
536
537 pub fn retry_policy(mut self, policy: ChildWorkflowRetryPolicy) -> Self {
538 self.retry_policy = Some(policy);
539 self
540 }
541
542 pub fn execution_timeout_seconds(mut self, seconds: u64) -> Self {
543 self.execution_timeout_seconds = Some(seconds);
544 self
545 }
546
547 pub fn run_timeout_seconds(mut self, seconds: u64) -> Self {
548 self.run_timeout_seconds = Some(seconds);
549 self
550 }
551}
552
553#[derive(Clone, Debug, PartialEq, Eq)]
555pub enum ActivityBackoff {
556 Explicit(Vec<Duration>),
559 Exponential {
561 initial_interval: Duration,
562 coefficient: u32,
563 maximum_interval: Option<Duration>,
564 },
565}
566
567#[derive(Clone, Debug, Default, PartialEq, Eq)]
569pub struct ActivityRetryPolicy {
570 pub max_attempts: Option<u32>,
571 pub backoff: Option<ActivityBackoff>,
572 pub non_retryable_error_types: Vec<String>,
573}
574
575impl ActivityRetryPolicy {
576 pub fn new(max_attempts: u32) -> Self {
578 Self {
579 max_attempts: Some(max_attempts),
580 ..Self::default()
581 }
582 }
583
584 pub fn backoff_intervals(mut self, intervals: impl IntoIterator<Item = Duration>) -> Self {
585 self.backoff = Some(ActivityBackoff::Explicit(intervals.into_iter().collect()));
586 self
587 }
588
589 pub fn exponential_backoff(
590 mut self,
591 initial_interval: Duration,
592 coefficient: u32,
593 maximum_interval: Option<Duration>,
594 ) -> Self {
595 self.backoff = Some(ActivityBackoff::Exponential {
596 initial_interval,
597 coefficient,
598 maximum_interval,
599 });
600 self
601 }
602
603 pub fn non_retryable_error_type(mut self, error_type: impl Into<String>) -> Self {
604 self.non_retryable_error_types.push(error_type.into());
605 self
606 }
607
608 pub fn non_retryable_error_types(
609 mut self,
610 error_types: impl IntoIterator<Item = impl Into<String>>,
611 ) -> Self {
612 self.non_retryable_error_types
613 .extend(error_types.into_iter().map(Into::into));
614 self
615 }
616}
617
618#[derive(Clone, Debug, Default, PartialEq, Eq)]
623pub struct ActivityOptions {
624 pub task_queue: Option<String>,
625 pub retry_policy: Option<ActivityRetryPolicy>,
626 pub start_to_close_timeout: Option<Duration>,
627 pub schedule_to_start_timeout: Option<Duration>,
628 pub schedule_to_close_timeout: Option<Duration>,
629 pub heartbeat_timeout: Option<Duration>,
630}
631
632impl ActivityOptions {
633 pub fn new() -> Self {
634 Self::default()
635 }
636
637 pub fn task_queue(mut self, task_queue: impl Into<String>) -> Self {
638 self.task_queue = Some(task_queue.into());
639 self
640 }
641
642 pub fn retry_policy(mut self, policy: ActivityRetryPolicy) -> Self {
643 self.retry_policy = Some(policy);
644 self
645 }
646
647 pub fn start_to_close_timeout(mut self, timeout: Duration) -> Self {
648 self.start_to_close_timeout = Some(timeout);
649 self
650 }
651
652 pub fn schedule_to_start_timeout(mut self, timeout: Duration) -> Self {
653 self.schedule_to_start_timeout = Some(timeout);
654 self
655 }
656
657 pub fn schedule_to_close_timeout(mut self, timeout: Duration) -> Self {
658 self.schedule_to_close_timeout = Some(timeout);
659 self
660 }
661
662 pub fn heartbeat_timeout(mut self, timeout: Duration) -> Self {
663 self.heartbeat_timeout = Some(timeout);
664 self
665 }
666
667 fn validate(&self) -> std::result::Result<ValidatedActivityOptions, ActivityOptionsError> {
668 if self
669 .task_queue
670 .as_deref()
671 .is_some_and(|queue| queue.trim().is_empty())
672 {
673 return Err(ActivityOptionsError::new(
674 ActivityOptionsErrorKind::EmptyTaskQueue,
675 Some("task_queue"),
676 "task_queue must not be empty",
677 ));
678 }
679
680 for (field, value) in [
681 ("start_to_close_timeout", self.start_to_close_timeout),
682 ("schedule_to_start_timeout", self.schedule_to_start_timeout),
683 ("schedule_to_close_timeout", self.schedule_to_close_timeout),
684 ("heartbeat_timeout", self.heartbeat_timeout),
685 ] {
686 if value.is_some_and(|value| value.is_zero()) {
687 return Err(ActivityOptionsError::new(
688 ActivityOptionsErrorKind::TimeoutNotPositive,
689 Some(field),
690 format!("{field} must be positive"),
691 ));
692 }
693 }
694
695 validate_timeout_order(
696 "heartbeat_timeout",
697 self.heartbeat_timeout,
698 "start_to_close_timeout",
699 self.start_to_close_timeout,
700 )?;
701 validate_timeout_order(
702 "start_to_close_timeout",
703 self.start_to_close_timeout,
704 "schedule_to_close_timeout",
705 self.schedule_to_close_timeout,
706 )?;
707 validate_timeout_order(
708 "schedule_to_start_timeout",
709 self.schedule_to_start_timeout,
710 "schedule_to_close_timeout",
711 self.schedule_to_close_timeout,
712 )?;
713
714 Ok(ValidatedActivityOptions {
715 task_queue: self.task_queue.clone(),
716 retry_policy: self
717 .retry_policy
718 .as_ref()
719 .map(validate_activity_retry_policy)
720 .transpose()?,
721 start_to_close_timeout: timeout_seconds(
722 "start_to_close_timeout",
723 self.start_to_close_timeout,
724 )?,
725 schedule_to_start_timeout: timeout_seconds(
726 "schedule_to_start_timeout",
727 self.schedule_to_start_timeout,
728 )?,
729 schedule_to_close_timeout: timeout_seconds(
730 "schedule_to_close_timeout",
731 self.schedule_to_close_timeout,
732 )?,
733 heartbeat_timeout: timeout_seconds("heartbeat_timeout", self.heartbeat_timeout)?,
734 })
735 }
736}
737
738#[derive(Clone, Debug)]
739struct ValidatedActivityOptions {
740 task_queue: Option<String>,
741 retry_policy: Option<Value>,
742 start_to_close_timeout: Option<u64>,
743 schedule_to_start_timeout: Option<u64>,
744 schedule_to_close_timeout: Option<u64>,
745 heartbeat_timeout: Option<u64>,
746}
747
748fn validate_timeout_order(
749 smaller_name: &'static str,
750 smaller: Option<Duration>,
751 larger_name: &'static str,
752 larger: Option<Duration>,
753) -> std::result::Result<(), ActivityOptionsError> {
754 if matches!((smaller, larger), (Some(smaller), Some(larger)) if smaller > larger) {
755 return Err(ActivityOptionsError::new(
756 ActivityOptionsErrorKind::TimeoutOrder,
757 Some(smaller_name),
758 format!("{smaller_name} must be <= {larger_name}"),
759 ));
760 }
761 Ok(())
762}
763
764fn timeout_seconds(
765 field: &'static str,
766 value: Option<Duration>,
767) -> std::result::Result<Option<u64>, ActivityOptionsError> {
768 value
769 .map(|value| {
770 activity_protocol_seconds(value).ok_or_else(|| {
771 ActivityOptionsError::new(
772 ActivityOptionsErrorKind::TimeoutOverflow,
773 Some(field),
774 format!("{field} is too large for the worker protocol"),
775 )
776 })
777 })
778 .transpose()
779}
780
781fn duration_seconds_ceil(value: Duration) -> Option<u64> {
782 value
783 .as_secs()
784 .checked_add(u64::from(value.subsec_nanos() > 0))
785}
786
787fn activity_protocol_seconds(value: Duration) -> Option<u64> {
788 duration_seconds_ceil(value).filter(|seconds| *seconds <= i64::MAX as u64)
789}
790
791fn validate_activity_retry_policy(
792 policy: &ActivityRetryPolicy,
793) -> std::result::Result<Value, ActivityOptionsError> {
794 if policy.max_attempts.is_none()
795 && policy.backoff.is_none()
796 && policy.non_retryable_error_types.is_empty()
797 {
798 return Err(ActivityOptionsError::new(
799 ActivityOptionsErrorKind::EmptyRetryPolicy,
800 Some("retry_policy"),
801 "retry_policy must configure at least one field",
802 ));
803 }
804 if policy.max_attempts == Some(0) {
805 return Err(ActivityOptionsError::new(
806 ActivityOptionsErrorKind::InvalidMaxAttempts,
807 Some("retry_policy.max_attempts"),
808 "max_attempts must be >= 1",
809 ));
810 }
811 if policy
812 .non_retryable_error_types
813 .iter()
814 .any(|error_type| error_type.trim().is_empty())
815 {
816 return Err(ActivityOptionsError::new(
817 ActivityOptionsErrorKind::EmptyNonRetryableErrorType,
818 Some("retry_policy.non_retryable_error_types"),
819 "non_retryable_error_types must not contain empty values",
820 ));
821 }
822
823 let backoff_seconds = match &policy.backoff {
824 None => None,
825 Some(backoff) => {
826 let max_attempts = policy.max_attempts.ok_or_else(|| {
827 ActivityOptionsError::new(
828 ActivityOptionsErrorKind::BackoffWithoutRetryBudget,
829 Some("retry_policy.backoff"),
830 "backoff requires max_attempts",
831 )
832 })?;
833 let retry_count = max_attempts.saturating_sub(1) as usize;
834 let intervals = match backoff {
835 ActivityBackoff::Explicit(intervals) => {
836 if intervals.len() > retry_count {
837 return Err(ActivityOptionsError::new(
838 ActivityOptionsErrorKind::TooManyBackoffIntervals,
839 Some("retry_policy.backoff"),
840 "backoff interval count must not exceed max_attempts - 1",
841 ));
842 }
843 intervals.clone()
844 }
845 ActivityBackoff::Exponential {
846 initial_interval,
847 coefficient,
848 maximum_interval,
849 } => {
850 if *coefficient < 1 {
851 return Err(ActivityOptionsError::new(
852 ActivityOptionsErrorKind::InvalidBackoffCoefficient,
853 Some("retry_policy.backoff.coefficient"),
854 "backoff coefficient must be >= 1",
855 ));
856 }
857 if retry_count > 10_000 {
858 return Err(ActivityOptionsError::new(
859 ActivityOptionsErrorKind::BackoffGenerationTooLarge,
860 Some("retry_policy.max_attempts"),
861 "generated backoff supports at most 10000 retry intervals",
862 ));
863 }
864 let mut current = *initial_interval;
865 let mut intervals = Vec::with_capacity(retry_count);
866 for _ in 0..retry_count {
867 let interval = maximum_interval
868 .map(|maximum| current.min(maximum))
869 .unwrap_or(current);
870 intervals.push(interval);
871 if maximum_interval.is_some_and(|maximum| interval == maximum) {
872 break;
873 }
874 current = current.checked_mul(*coefficient).ok_or_else(|| {
875 ActivityOptionsError::new(
876 ActivityOptionsErrorKind::BackoffOverflow,
877 Some("retry_policy.backoff"),
878 "generated backoff interval overflowed",
879 )
880 })?;
881 }
882 intervals
883 }
884 };
885 Some(
886 intervals
887 .into_iter()
888 .map(|interval| {
889 activity_protocol_seconds(interval).ok_or_else(|| {
890 ActivityOptionsError::new(
891 ActivityOptionsErrorKind::BackoffOverflow,
892 Some("retry_policy.backoff"),
893 "backoff interval is too large for the worker protocol",
894 )
895 })
896 })
897 .collect::<std::result::Result<Vec<_>, _>>()?,
898 )
899 }
900 };
901
902 let mut encoded = serde_json::Map::new();
903 if let Some(max_attempts) = policy.max_attempts {
904 encoded.insert("max_attempts".to_string(), json!(max_attempts));
905 }
906 if let Some(backoff_seconds) = backoff_seconds {
907 encoded.insert("backoff_seconds".to_string(), json!(backoff_seconds));
908 }
909 if !policy.non_retryable_error_types.is_empty() {
910 let mut canonical_error_types = Vec::new();
911 for error_type in policy
912 .non_retryable_error_types
913 .iter()
914 .map(|error_type| error_type.trim())
915 {
916 if !canonical_error_types.contains(&error_type) {
917 canonical_error_types.push(error_type);
918 }
919 }
920 encoded.insert(
921 "non_retryable_error_types".to_string(),
922 json!(canonical_error_types),
923 );
924 }
925 Ok(Value::Object(encoded))
926}
927
928#[derive(Clone, Debug, Error)]
931#[error("non-deterministic workflow replay ({reason}) at sequence {sequence:?}: {message}")]
932pub struct ReplayFailure {
933 pub reason: String,
934 pub sequence: Option<u64>,
935 pub expected: Option<String>,
936 pub actual: Option<String>,
937 pub message: String,
938}
939
940impl ReplayFailure {
941 fn new(
942 reason: impl Into<String>,
943 sequence: Option<u64>,
944 expected: Option<String>,
945 actual: Option<String>,
946 message: impl Into<String>,
947 ) -> Self {
948 Self {
949 reason: reason.into(),
950 sequence,
951 expected,
952 actual,
953 message: message.into(),
954 }
955 }
956}
957
958#[derive(Clone, Debug, Error)]
960#[error("query failed ({reason}, HTTP {status}): {message}")]
961pub struct QueryFailure {
962 pub status: u16,
963 pub reason: String,
964 pub message: String,
965 pub body: Value,
966}
967
968#[derive(Clone, Debug, Error)]
970#[error("protocol rejected ({reason}, HTTP {status}): {message}")]
971pub struct ProtocolFailure {
972 pub status: u16,
973 pub reason: String,
974 pub message: String,
975 pub supported_version: Option<String>,
976 pub requested_version: Option<String>,
977 pub body: Value,
978}
979
980#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
981pub struct PayloadEnvelope {
982 pub codec: String,
983 pub blob: String,
984}
985
986impl PayloadEnvelope {
987 pub fn avro<T: Serialize>(value: &T) -> Result<Self> {
988 encode_payload(value, DEFAULT_CODEC)
989 }
990
991 pub fn json<T: Serialize>(value: &T) -> Result<Self> {
992 encode_payload(value, JSON_CODEC)
993 }
994}
995
996pub fn encode_payload<T: Serialize>(value: &T, codec: &str) -> Result<PayloadEnvelope> {
997 let value = serde_json::to_value(value)?;
998 let blob = encode_value_blob(&value, codec)?;
999
1000 Ok(PayloadEnvelope {
1001 codec: codec.to_string(),
1002 blob,
1003 })
1004}
1005
1006pub fn decode_payload<T: DeserializeOwned>(envelope: &PayloadEnvelope) -> Result<T> {
1007 let value = decode_blob(&envelope.blob, &envelope.codec)?;
1008 Ok(serde_json::from_value(value)?)
1009}
1010
1011fn encode_value_envelope(value: &Value, codec: &str) -> Result<Value> {
1012 Ok(serde_json::to_value(encode_payload(value, codec)?)?)
1013}
1014
1015fn encode_value_blob(value: &Value, codec: &str) -> Result<String> {
1016 match codec {
1017 JSON_CODEC => Ok(serde_json::to_string(value)?),
1018 DEFAULT_CODEC => encode_avro_generic(value),
1019 other => Err(Error::Codec(format!("unsupported payload codec {other:?}"))),
1020 }
1021}
1022
1023fn decode_wire_value(value: &Value, fallback_codec: &str) -> Result<Value> {
1024 if value.is_null() {
1025 return Ok(Value::Null);
1026 }
1027
1028 if let Some(object) = value.as_object() {
1029 if let (Some(codec), Some(blob)) = (
1030 object.get("codec").and_then(Value::as_str),
1031 object.get("blob").and_then(Value::as_str),
1032 ) {
1033 return decode_blob(blob, codec);
1034 }
1035 }
1036
1037 if let Some(blob) = value.as_str() {
1038 return decode_blob(blob, fallback_codec);
1039 }
1040
1041 Ok(value.clone())
1042}
1043
1044fn decode_blob(blob: &str, codec: &str) -> Result<Value> {
1045 match codec {
1046 JSON_CODEC => Ok(serde_json::from_str(blob)?),
1047 DEFAULT_CODEC => decode_avro_generic(blob),
1048 other => Err(Error::Codec(format!("unsupported payload codec {other:?}"))),
1049 }
1050}
1051
1052fn encode_avro_generic(value: &Value) -> Result<String> {
1053 let json = serde_json::to_string(value)?;
1054 let datum = to_value(AvroPayload {
1055 json,
1056 version: AVRO_PAYLOAD_VERSION,
1057 })
1058 .map_err(|err| Error::Codec(format!("could not convert avro generic wrapper: {err}")))?;
1059 let datum = to_avro_datum(avro_payload_schema()?, datum)
1060 .map_err(|err| Error::Codec(format!("could not encode avro generic wrapper: {err}")))?;
1061
1062 let mut bytes = Vec::with_capacity(datum.len() + 1);
1063 bytes.push(0x00);
1064 bytes.extend_from_slice(&datum);
1065 Ok(BASE64.encode(bytes))
1066}
1067
1068fn decode_avro_generic(blob: &str) -> Result<Value> {
1069 let bytes = BASE64
1070 .decode(blob)
1071 .map_err(|err| Error::Codec(format!("invalid avro base64 payload: {err}")))?;
1072
1073 if bytes.is_empty() {
1074 return Err(Error::Codec("avro payload is empty".to_string()));
1075 }
1076
1077 match bytes[0] {
1078 0x00 => {}
1079 0x01 => {
1080 return Err(Error::Codec(
1081 "typed avro payloads require a schema context; v1 supports the generic wrapper"
1082 .to_string(),
1083 ));
1084 }
1085 other => {
1086 return Err(Error::Codec(format!(
1087 "unknown avro payload prefix 0x{other:02x}"
1088 )));
1089 }
1090 }
1091
1092 let mut datum = &bytes[1..];
1093 let datum = from_avro_datum(avro_payload_schema()?, &mut datum, None)
1094 .map_err(|err| Error::Codec(format!("could not decode avro generic wrapper: {err}")))?;
1095 let payload: AvroPayload = from_value(&datum)
1096 .map_err(|err| Error::Codec(format!("invalid avro generic wrapper record: {err}")))?;
1097
1098 if payload.version != AVRO_PAYLOAD_VERSION {
1099 return Err(Error::Codec(format!(
1100 "unsupported avro generic wrapper version {}",
1101 payload.version
1102 )));
1103 }
1104
1105 Ok(serde_json::from_str(&payload.json)?)
1106}
1107
1108#[derive(Debug, Serialize, Deserialize)]
1109struct AvroPayload {
1110 json: String,
1111 version: i32,
1112}
1113
1114fn avro_payload_schema() -> Result<&'static Schema> {
1115 match AVRO_PAYLOAD_SCHEMA.get_or_init(|| {
1116 Schema::parse_str(AVRO_PAYLOAD_SCHEMA_JSON)
1117 .map_err(|err| format!("could not parse avro payload schema: {err}"))
1118 }) {
1119 Ok(schema) => Ok(schema),
1120 Err(message) => Err(Error::Codec(message.clone())),
1121 }
1122}
1123
1124#[derive(Clone, Debug)]
1125pub struct Client {
1126 http: reqwest::Client,
1127 base_url: String,
1128 token: Option<String>,
1129 control_token: Option<String>,
1130 worker_token: Option<String>,
1131 namespace: String,
1132}
1133
1134impl Client {
1135 pub fn new(base_url: impl Into<String>) -> Result<Self> {
1136 Self::builder(base_url).build()
1137 }
1138
1139 pub fn builder(base_url: impl Into<String>) -> ClientBuilder {
1140 ClientBuilder {
1141 base_url: base_url.into(),
1142 token: None,
1143 control_token: None,
1144 worker_token: None,
1145 namespace: "default".to_string(),
1146 timeout: Duration::from_secs(60),
1147 }
1148 }
1149
1150 pub async fn health(&self) -> Result<Value> {
1151 self.request_json(
1152 reqwest::Method::GET,
1153 "/health",
1154 RequestProtocol::ControlPlane,
1155 Option::<&Value>::None,
1156 )
1157 .await
1158 }
1159
1160 pub async fn cluster_info(&self) -> Result<Value> {
1161 self.request_json(
1162 reqwest::Method::GET,
1163 "/cluster/info",
1164 RequestProtocol::ControlPlane,
1165 Option::<&Value>::None,
1166 )
1167 .await
1168 }
1169
1170 pub async fn start_workflow<T: Serialize>(
1171 &self,
1172 workflow_type: &str,
1173 task_queue: &str,
1174 workflow_id: &str,
1175 input: T,
1176 ) -> Result<WorkflowHandle> {
1177 self.start_workflow_with_options(
1178 workflow_type,
1179 task_queue,
1180 workflow_id,
1181 WorkflowStartOptions::default(),
1182 input,
1183 )
1184 .await
1185 }
1186
1187 pub async fn start_workflow_with_options<T: Serialize>(
1190 &self,
1191 workflow_type: &str,
1192 task_queue: &str,
1193 workflow_id: &str,
1194 options: WorkflowStartOptions,
1195 input: T,
1196 ) -> Result<WorkflowHandle> {
1197 options.validate()?;
1198 let input = serde_json::to_value(input)?;
1199 let input_envelope = encode_value_envelope(&normalize_arguments(input), DEFAULT_CODEC)?;
1200 let body = json!({
1201 "workflow_id": workflow_id,
1202 "workflow_type": workflow_type,
1203 "task_queue": task_queue,
1204 "input": input_envelope,
1205 "execution_timeout_seconds": options.execution_timeout_seconds,
1206 "run_timeout_seconds": options.run_timeout_seconds
1207 });
1208
1209 let data: Value = self
1210 .request_json(
1211 reqwest::Method::POST,
1212 "/workflows",
1213 RequestProtocol::ControlPlane,
1214 Some(&body),
1215 )
1216 .await?;
1217
1218 Ok(WorkflowHandle {
1219 client: self.clone(),
1220 workflow_id: data
1221 .get("workflow_id")
1222 .and_then(Value::as_str)
1223 .unwrap_or(workflow_id)
1224 .to_string(),
1225 run_id: data
1226 .get("run_id")
1227 .and_then(Value::as_str)
1228 .map(str::to_string),
1229 workflow_type: data
1230 .get("workflow_type")
1231 .and_then(Value::as_str)
1232 .unwrap_or(workflow_type)
1233 .to_string(),
1234 })
1235 }
1236
1237 pub async fn signal_workflow<T: Serialize>(
1238 &self,
1239 workflow_id: &str,
1240 signal_name: &str,
1241 input: T,
1242 ) -> Result<Value> {
1243 self.signal_workflow_target(workflow_id, None, signal_name, input)
1244 .await
1245 }
1246
1247 pub async fn signal_workflow_run<T: Serialize>(
1249 &self,
1250 workflow_id: &str,
1251 run_id: &str,
1252 signal_name: &str,
1253 input: T,
1254 ) -> Result<Value> {
1255 self.signal_workflow_target(workflow_id, Some(run_id), signal_name, input)
1256 .await
1257 }
1258
1259 async fn signal_workflow_target<T: Serialize>(
1260 &self,
1261 workflow_id: &str,
1262 run_id: Option<&str>,
1263 signal_name: &str,
1264 input: T,
1265 ) -> Result<Value> {
1266 let input = serde_json::to_value(input)?;
1267 let input_envelope = encode_value_envelope(&normalize_arguments(input), DEFAULT_CODEC)?;
1268 let body = json!({
1269 "input": input_envelope
1270 });
1271 let path = match run_id {
1272 Some(run_id) => {
1273 format!("/workflows/{workflow_id}/runs/{run_id}/signal/{signal_name}")
1274 }
1275 None => format!("/workflows/{workflow_id}/signal/{signal_name}"),
1276 };
1277 self.request_json(
1278 reqwest::Method::POST,
1279 &path,
1280 RequestProtocol::ControlPlane,
1281 Some(&body),
1282 )
1283 .await
1284 }
1285
1286 pub async fn cancel_workflow(
1288 &self,
1289 workflow_id: &str,
1290 options: WorkflowCommandOptions,
1291 ) -> Result<WorkflowCommandResult> {
1292 self.workflow_command(workflow_id, None, WorkflowCommandKind::Cancel, options)
1293 .await
1294 }
1295
1296 pub async fn cancel_workflow_run(
1298 &self,
1299 workflow_id: &str,
1300 run_id: &str,
1301 options: WorkflowCommandOptions,
1302 ) -> Result<WorkflowCommandResult> {
1303 self.workflow_command(
1304 workflow_id,
1305 Some(run_id),
1306 WorkflowCommandKind::Cancel,
1307 options,
1308 )
1309 .await
1310 }
1311
1312 pub async fn terminate_workflow(
1314 &self,
1315 workflow_id: &str,
1316 options: WorkflowCommandOptions,
1317 ) -> Result<WorkflowCommandResult> {
1318 self.workflow_command(workflow_id, None, WorkflowCommandKind::Terminate, options)
1319 .await
1320 }
1321
1322 pub async fn terminate_workflow_run(
1324 &self,
1325 workflow_id: &str,
1326 run_id: &str,
1327 options: WorkflowCommandOptions,
1328 ) -> Result<WorkflowCommandResult> {
1329 self.workflow_command(
1330 workflow_id,
1331 Some(run_id),
1332 WorkflowCommandKind::Terminate,
1333 options,
1334 )
1335 .await
1336 }
1337
1338 async fn workflow_command(
1339 &self,
1340 workflow_id: &str,
1341 run_id: Option<&str>,
1342 command: WorkflowCommandKind,
1343 options: WorkflowCommandOptions,
1344 ) -> Result<WorkflowCommandResult> {
1345 let path = match run_id {
1346 Some(run_id) => format!(
1347 "/workflows/{workflow_id}/runs/{run_id}/{}",
1348 command.as_str()
1349 ),
1350 None => format!("/workflows/{workflow_id}/{}", command.as_str()),
1351 };
1352 let data = match self
1353 .request_json(
1354 reqwest::Method::POST,
1355 &path,
1356 RequestProtocol::ControlPlane,
1357 Some(&options),
1358 )
1359 .await
1360 {
1361 Ok(data) => data,
1362 Err(Error::Http { status, body }) => {
1363 return Err(Error::WorkflowCommandRejected(workflow_command_rejection(
1364 command,
1365 status,
1366 body,
1367 workflow_id,
1368 run_id,
1369 )));
1370 }
1371 Err(error) => return Err(error),
1372 };
1373
1374 Ok(workflow_command_result(command, data, workflow_id, run_id))
1375 }
1376
1377 pub async fn query_workflow<T: Serialize>(
1383 &self,
1384 workflow_id: &str,
1385 query_name: &str,
1386 input: T,
1387 ) -> Result<Value> {
1388 self.query_workflow_target(workflow_id, None, query_name, input)
1389 .await
1390 }
1391
1392 pub async fn query_workflow_run<T: Serialize>(
1394 &self,
1395 workflow_id: &str,
1396 run_id: &str,
1397 query_name: &str,
1398 input: T,
1399 ) -> Result<Value> {
1400 self.query_workflow_target(workflow_id, Some(run_id), query_name, input)
1401 .await
1402 }
1403
1404 async fn query_workflow_target<T: Serialize>(
1405 &self,
1406 workflow_id: &str,
1407 run_id: Option<&str>,
1408 query_name: &str,
1409 input: T,
1410 ) -> Result<Value> {
1411 let input = serde_json::to_value(input)?;
1412 let input_envelope = encode_value_envelope(&normalize_arguments(input), DEFAULT_CODEC)?;
1413 let body = json!({
1414 "input": input_envelope
1415 });
1416 let path = match run_id {
1417 Some(run_id) => {
1418 format!("/workflows/{workflow_id}/runs/{run_id}/query/{query_name}")
1419 }
1420 None => format!("/workflows/{workflow_id}/query/{query_name}"),
1421 };
1422 let response: Value = match self
1423 .request_json(
1424 reqwest::Method::POST,
1425 &path,
1426 RequestProtocol::ControlPlane,
1427 Some(&body),
1428 )
1429 .await
1430 {
1431 Ok(response) => response,
1432 Err(Error::Http { status, body }) => {
1433 return Err(Error::QueryFailed(query_failure(status, body)));
1434 }
1435 Err(error) => return Err(error),
1436 };
1437
1438 if let Some(envelope) = response
1439 .get("result_envelope")
1440 .filter(|envelope| !envelope.is_null())
1441 {
1442 return decode_wire_value(envelope, DEFAULT_CODEC);
1443 }
1444
1445 Ok(response.get("result").cloned().unwrap_or(Value::Null))
1446 }
1447
1448 pub async fn describe_workflow(&self, workflow_id: &str) -> Result<WorkflowDescription> {
1449 let path = format!("/workflows/{workflow_id}");
1450 let mut data: WorkflowDescription = self
1451 .request_json(
1452 reqwest::Method::GET,
1453 &path,
1454 RequestProtocol::ControlPlane,
1455 Option::<&Value>::None,
1456 )
1457 .await?;
1458 data.decode_payloads()?;
1459 Ok(data)
1460 }
1461
1462 pub async fn describe_workflow_run(
1464 &self,
1465 workflow_id: &str,
1466 run_id: &str,
1467 ) -> Result<WorkflowDescription> {
1468 let path = format!("/workflows/{workflow_id}/runs/{run_id}");
1469 let mut data: WorkflowDescription = self
1470 .request_json(
1471 reqwest::Method::GET,
1472 &path,
1473 RequestProtocol::ControlPlane,
1474 Option::<&Value>::None,
1475 )
1476 .await?;
1477 data.decode_payloads()?;
1478 Ok(data)
1479 }
1480
1481 pub async fn register_worker(
1482 &self,
1483 worker_id: &str,
1484 task_queue: &str,
1485 supported_workflow_types: Vec<String>,
1486 supported_activity_types: Vec<String>,
1487 max_concurrent_workflow_tasks: usize,
1488 max_concurrent_activity_tasks: usize,
1489 ) -> Result<RegisterWorkerResponse> {
1490 self.register_worker_with_capabilities(
1491 worker_id,
1492 task_queue,
1493 supported_workflow_types,
1494 supported_activity_types,
1495 max_concurrent_workflow_tasks,
1496 max_concurrent_activity_tasks,
1497 Vec::new(),
1498 )
1499 .await
1500 }
1501
1502 pub async fn register_worker_with_capabilities(
1504 &self,
1505 worker_id: &str,
1506 task_queue: &str,
1507 supported_workflow_types: Vec<String>,
1508 supported_activity_types: Vec<String>,
1509 max_concurrent_workflow_tasks: usize,
1510 max_concurrent_activity_tasks: usize,
1511 capabilities: Vec<String>,
1512 ) -> Result<RegisterWorkerResponse> {
1513 let body = json!({
1514 "worker_id": worker_id,
1515 "task_queue": task_queue,
1516 "runtime": "rust",
1517 "sdk_version": SDK_VERSION,
1518 "supported_workflow_types": supported_workflow_types,
1519 "supported_activity_types": supported_activity_types,
1520 "capabilities": capabilities,
1521 "max_concurrent_workflow_tasks": max_concurrent_workflow_tasks,
1522 "max_concurrent_activity_tasks": max_concurrent_activity_tasks
1523 });
1524
1525 self.request_json(
1526 reqwest::Method::POST,
1527 "/worker/register",
1528 RequestProtocol::Worker(WORKER_PROTOCOL_VERSION),
1529 Some(&body),
1530 )
1531 .await
1532 }
1533
1534 pub async fn poll_query_task(
1536 &self,
1537 worker_id: &str,
1538 task_queue: &str,
1539 timeout: Duration,
1540 ) -> Result<Option<QueryTask>> {
1541 Ok(self
1542 .poll_query_task_response(worker_id, task_queue, timeout)
1543 .await?
1544 .task)
1545 }
1546
1547 pub async fn poll_query_task_response(
1549 &self,
1550 worker_id: &str,
1551 task_queue: &str,
1552 timeout: Duration,
1553 ) -> Result<PollQueryTaskResponse> {
1554 let timeout_seconds = long_poll_timeout_seconds(timeout);
1555 let body = json!({
1556 "worker_id": worker_id,
1557 "task_queue": task_queue,
1558 "poll_request_id": unique_request_id("rust-query-poll"),
1559 "timeout_seconds": timeout_seconds,
1560 });
1561 worker_poll_response(
1562 self.request_json_with_timeout(
1563 reqwest::Method::POST,
1564 "/worker/query-tasks/poll",
1565 RequestProtocol::Worker(QUERY_TASK_MINIMUM_WORKER_PROTOCOL_VERSION),
1566 Some(&body),
1567 timeout + Duration::from_secs(5),
1568 )
1569 .await,
1570 )
1571 }
1572
1573 pub async fn complete_query_task(
1575 &self,
1576 query_task_id: &str,
1577 lease_owner: &str,
1578 query_task_attempt: u64,
1579 result: Value,
1580 codec: &str,
1581 ) -> Result<Value> {
1582 let result_envelope = encode_value_envelope(&result, codec)?;
1583 self.complete_query_task_with_envelope(
1584 query_task_id,
1585 lease_owner,
1586 query_task_attempt,
1587 result,
1588 result_envelope,
1589 )
1590 .await
1591 }
1592
1593 async fn complete_query_task_with_envelope(
1594 &self,
1595 query_task_id: &str,
1596 lease_owner: &str,
1597 query_task_attempt: u64,
1598 result: Value,
1599 result_envelope: Value,
1600 ) -> Result<Value> {
1601 let body = json!({
1602 "lease_owner": lease_owner,
1603 "query_task_attempt": query_task_attempt,
1604 "result": result,
1605 "result_envelope": result_envelope,
1606 });
1607 let path = format!("/worker/query-tasks/{query_task_id}/complete");
1608 let response = self
1609 .request_json(
1610 reqwest::Method::POST,
1611 &path,
1612 RequestProtocol::Worker(QUERY_TASK_MINIMUM_WORKER_PROTOCOL_VERSION),
1613 Some(&body),
1614 )
1615 .await;
1616 query_task_response(response)
1617 }
1618
1619 pub async fn fail_query_task(
1621 &self,
1622 query_task_id: &str,
1623 lease_owner: &str,
1624 query_task_attempt: u64,
1625 message: impl Into<String>,
1626 reason: impl Into<String>,
1627 failure_type: impl Into<String>,
1628 ) -> Result<Value> {
1629 let body = json!({
1630 "lease_owner": lease_owner,
1631 "query_task_attempt": query_task_attempt,
1632 "failure": {
1633 "message": message.into(),
1634 "reason": reason.into(),
1635 "type": failure_type.into(),
1636 }
1637 });
1638 let path = format!("/worker/query-tasks/{query_task_id}/fail");
1639 let response = self
1640 .request_json(
1641 reqwest::Method::POST,
1642 &path,
1643 RequestProtocol::Worker(QUERY_TASK_MINIMUM_WORKER_PROTOCOL_VERSION),
1644 Some(&body),
1645 )
1646 .await;
1647 query_task_response(response)
1648 }
1649
1650 pub async fn heartbeat_worker(
1651 &self,
1652 worker_id: &str,
1653 workflow_available: usize,
1654 activity_available: usize,
1655 ) -> Result<Value> {
1656 let body = json!({
1657 "worker_id": worker_id,
1658 "task_slots": {
1659 "workflow_available": workflow_available,
1660 "activity_available": activity_available
1661 },
1662 "process_metrics": {
1663 "process_id": std::process::id(),
1664 "process_uptime_seconds": 0
1665 }
1666 });
1667
1668 self.request_json(
1669 reqwest::Method::POST,
1670 "/worker/heartbeat",
1671 RequestProtocol::Worker(WORKER_PROTOCOL_VERSION),
1672 Some(&body),
1673 )
1674 .await
1675 }
1676
1677 pub async fn poll_workflow_task(
1678 &self,
1679 worker_id: &str,
1680 task_queue: &str,
1681 timeout: Duration,
1682 ) -> Result<Option<WorkflowTask>> {
1683 Ok(self
1684 .poll_workflow_task_response(worker_id, task_queue, timeout)
1685 .await?
1686 .task)
1687 }
1688
1689 pub async fn poll_workflow_task_response(
1690 &self,
1691 worker_id: &str,
1692 task_queue: &str,
1693 timeout: Duration,
1694 ) -> Result<PollWorkflowTaskResponse> {
1695 let body = json!({
1696 "worker_id": worker_id,
1697 "task_queue": task_queue,
1698 "timeout_seconds": long_poll_timeout_seconds(timeout),
1699 });
1700 let mut data: PollWorkflowTaskResponse = worker_poll_response(
1701 self.request_json_with_timeout(
1702 reqwest::Method::POST,
1703 "/worker/workflow-tasks/poll",
1704 RequestProtocol::Worker(WORKER_PROTOCOL_VERSION),
1705 Some(&body),
1706 timeout + Duration::from_secs(5),
1707 )
1708 .await,
1709 )?;
1710
1711 if let Some(task) = data.task.as_mut() {
1712 self.fetch_remaining_workflow_history(worker_id, task)
1713 .await?;
1714 }
1715
1716 Ok(data)
1717 }
1718
1719 async fn fetch_remaining_workflow_history(
1720 &self,
1721 worker_id: &str,
1722 task: &mut WorkflowTask,
1723 ) -> Result<()> {
1724 let mut next_token = task.next_history_page_token.clone();
1725
1726 while let Some(token) = next_token.take().filter(|token| !token.is_empty()) {
1727 let lease_owner = task
1728 .lease_owner
1729 .clone()
1730 .unwrap_or_else(|| worker_id.to_string());
1731 let page = self
1732 .workflow_task_history_page(
1733 &task.task_id,
1734 &lease_owner,
1735 task.workflow_task_attempt,
1736 &token,
1737 )
1738 .await?;
1739
1740 task.append_history_page(page);
1741
1742 if task.next_history_page_token.as_deref() == Some(token.as_str()) {
1743 return Err(Error::Codec(
1744 "workflow history pagination returned the same page token".to_string(),
1745 ));
1746 }
1747
1748 next_token = task.next_history_page_token.clone();
1749 }
1750
1751 Ok(())
1752 }
1753
1754 async fn workflow_task_history_page(
1755 &self,
1756 task_id: &str,
1757 lease_owner: &str,
1758 workflow_task_attempt: u64,
1759 next_history_page_token: &str,
1760 ) -> Result<WorkflowTaskHistoryPage> {
1761 let body = json!({
1762 "lease_owner": lease_owner,
1763 "workflow_task_attempt": workflow_task_attempt,
1764 "next_history_page_token": next_history_page_token
1765 });
1766 let path = format!("/worker/workflow-tasks/{task_id}/history");
1767
1768 self.request_json(
1769 reqwest::Method::POST,
1770 &path,
1771 RequestProtocol::Worker(WORKER_PROTOCOL_VERSION),
1772 Some(&body),
1773 )
1774 .await
1775 }
1776
1777 pub async fn complete_workflow_task(
1778 &self,
1779 task_id: &str,
1780 lease_owner: &str,
1781 workflow_task_attempt: u64,
1782 commands: Vec<Value>,
1783 ) -> Result<Value> {
1784 let body = json!({
1785 "lease_owner": lease_owner,
1786 "workflow_task_attempt": workflow_task_attempt,
1787 "commands": commands
1788 });
1789 let path = format!("/worker/workflow-tasks/{task_id}/complete");
1790 self.request_json(
1791 reqwest::Method::POST,
1792 &path,
1793 RequestProtocol::Worker(WORKER_PROTOCOL_VERSION),
1794 Some(&body),
1795 )
1796 .await
1797 }
1798
1799 pub async fn fail_workflow_task(
1800 &self,
1801 task_id: &str,
1802 lease_owner: &str,
1803 workflow_task_attempt: u64,
1804 message: impl Into<String>,
1805 ) -> Result<Value> {
1806 self.fail_workflow_task_with_type(
1807 task_id,
1808 lease_owner,
1809 workflow_task_attempt,
1810 message,
1811 "RustWorkflowTaskFailure",
1812 )
1813 .await
1814 }
1815
1816 async fn fail_workflow_task_with_type(
1817 &self,
1818 task_id: &str,
1819 lease_owner: &str,
1820 workflow_task_attempt: u64,
1821 message: impl Into<String>,
1822 failure_type: &str,
1823 ) -> Result<Value> {
1824 let body = json!({
1825 "lease_owner": lease_owner,
1826 "workflow_task_attempt": workflow_task_attempt,
1827 "failure": {
1828 "message": message.into(),
1829 "type": failure_type
1830 }
1831 });
1832 let path = format!("/worker/workflow-tasks/{task_id}/fail");
1833 self.request_json(
1834 reqwest::Method::POST,
1835 &path,
1836 RequestProtocol::Worker(WORKER_PROTOCOL_VERSION),
1837 Some(&body),
1838 )
1839 .await
1840 }
1841
1842 pub async fn poll_activity_task(
1843 &self,
1844 worker_id: &str,
1845 task_queue: &str,
1846 timeout: Duration,
1847 ) -> Result<Option<ActivityTask>> {
1848 Ok(self
1849 .poll_activity_task_response(worker_id, task_queue, timeout)
1850 .await?
1851 .task)
1852 }
1853
1854 pub async fn poll_activity_task_response(
1856 &self,
1857 worker_id: &str,
1858 task_queue: &str,
1859 timeout: Duration,
1860 ) -> Result<PollActivityTaskResponse> {
1861 let body = json!({
1862 "worker_id": worker_id,
1863 "task_queue": task_queue,
1864 "timeout_seconds": long_poll_timeout_seconds(timeout),
1865 });
1866 let data: PollActivityTaskResponse = worker_poll_response(
1867 self.request_json_with_timeout(
1868 reqwest::Method::POST,
1869 "/worker/activity-tasks/poll",
1870 RequestProtocol::Worker(WORKER_PROTOCOL_VERSION),
1871 Some(&body),
1872 timeout + Duration::from_secs(5),
1873 )
1874 .await,
1875 )?;
1876 Ok(data)
1877 }
1878
1879 pub async fn complete_activity_task(
1880 &self,
1881 task_id: &str,
1882 activity_attempt_id: &str,
1883 lease_owner: &str,
1884 result: Value,
1885 codec: &str,
1886 ) -> Result<Value> {
1887 let result = encode_value_envelope(&result, codec)?;
1888 let body = json!({
1889 "activity_attempt_id": activity_attempt_id,
1890 "lease_owner": lease_owner,
1891 "result": result
1892 });
1893 let path = format!("/worker/activity-tasks/{task_id}/complete");
1894 activity_task_response(
1895 self.request_json(
1896 reqwest::Method::POST,
1897 &path,
1898 RequestProtocol::Worker(WORKER_PROTOCOL_VERSION),
1899 Some(&body),
1900 )
1901 .await,
1902 "complete",
1903 task_id,
1904 activity_attempt_id,
1905 )
1906 }
1907
1908 pub async fn fail_activity_task(
1909 &self,
1910 task_id: &str,
1911 activity_attempt_id: &str,
1912 lease_owner: &str,
1913 message: impl Into<String>,
1914 non_retryable: bool,
1915 ) -> Result<Value> {
1916 let body = json!({
1917 "activity_attempt_id": activity_attempt_id,
1918 "lease_owner": lease_owner,
1919 "failure": {
1920 "message": message.into(),
1921 "type": "RustActivityFailure",
1922 "non_retryable": non_retryable
1923 }
1924 });
1925 let path = format!("/worker/activity-tasks/{task_id}/fail");
1926 activity_task_response(
1927 self.request_json(
1928 reqwest::Method::POST,
1929 &path,
1930 RequestProtocol::Worker(WORKER_PROTOCOL_VERSION),
1931 Some(&body),
1932 )
1933 .await,
1934 "fail",
1935 task_id,
1936 activity_attempt_id,
1937 )
1938 }
1939
1940 pub async fn heartbeat_activity_task(
1941 &self,
1942 task_id: &str,
1943 activity_attempt_id: &str,
1944 lease_owner: &str,
1945 details: Value,
1946 ) -> Result<ActivityHeartbeatResponse> {
1947 let body = json!({
1948 "activity_attempt_id": activity_attempt_id,
1949 "lease_owner": lease_owner,
1950 "details": details
1951 });
1952 let path = format!("/worker/activity-tasks/{task_id}/heartbeat");
1953 activity_task_response(
1954 self.request_json(
1955 reqwest::Method::POST,
1956 &path,
1957 RequestProtocol::Worker(WORKER_PROTOCOL_VERSION),
1958 Some(&body),
1959 )
1960 .await,
1961 "heartbeat",
1962 task_id,
1963 activity_attempt_id,
1964 )
1965 }
1966
1967 async fn request_json<T: DeserializeOwned, B: Serialize + ?Sized>(
1968 &self,
1969 method: reqwest::Method,
1970 path: &str,
1971 protocol: RequestProtocol,
1972 body: Option<&B>,
1973 ) -> Result<T> {
1974 self.request_json_with_timeout(method, path, protocol, body, Duration::from_secs(60))
1975 .await
1976 }
1977
1978 async fn request_json_with_timeout<T: DeserializeOwned, B: Serialize + ?Sized>(
1979 &self,
1980 method: reqwest::Method,
1981 path: &str,
1982 protocol: RequestProtocol,
1983 body: Option<&B>,
1984 timeout: Duration,
1985 ) -> Result<T> {
1986 let mut request = self
1987 .http
1988 .request(method, format!("{}/api{}", self.base_url, path))
1989 .timeout(timeout)
1990 .header(reqwest::header::ACCEPT, "application/json")
1991 .header(reqwest::header::CONTENT_TYPE, "application/json")
1992 .header("X-Namespace", &self.namespace);
1993
1994 match protocol {
1995 RequestProtocol::Worker(version) => {
1996 request = request.header("X-Durable-Workflow-Protocol-Version", version);
1997 }
1998 RequestProtocol::ControlPlane => {
1999 request = request.header(
2000 "X-Durable-Workflow-Control-Plane-Version",
2001 CONTROL_PLANE_VERSION,
2002 );
2003 }
2004 }
2005
2006 if let Some(token) = self.auth_token(protocol.is_worker()) {
2007 request = request.bearer_auth(token);
2008 }
2009
2010 if let Some(body) = body {
2011 request = request.json(body);
2012 }
2013
2014 let response = request.send().await?;
2015 let status = response.status();
2016 let bytes = response.bytes().await?;
2017
2018 if !status.is_success() {
2019 let body = String::from_utf8_lossy(&bytes).to_string();
2020 if let Some(protocol) = protocol_failure(status, &body) {
2021 return Err(Error::Protocol(protocol));
2022 }
2023 return Err(Error::Http { status, body });
2024 }
2025
2026 if bytes.is_empty() {
2027 return Ok(serde_json::from_value(Value::Null)?);
2028 }
2029
2030 Ok(serde_json::from_slice(&bytes)?)
2031 }
2032
2033 fn auth_token(&self, worker: bool) -> Option<&str> {
2034 if worker {
2035 self.worker_token
2036 .as_deref()
2037 .or(self.token.as_deref())
2038 .or(self.control_token.as_deref())
2039 } else {
2040 self.control_token
2041 .as_deref()
2042 .or(self.token.as_deref())
2043 .or(self.worker_token.as_deref())
2044 }
2045 }
2046}
2047
2048fn query_failure(status: reqwest::StatusCode, raw_body: String) -> QueryFailure {
2049 let body = serde_json::from_str(&raw_body).unwrap_or_else(|_| json!({"message": raw_body}));
2050 let reason = body
2051 .get("reason")
2052 .and_then(Value::as_str)
2053 .unwrap_or("query_rejected")
2054 .to_string();
2055 let message = body
2056 .get("message")
2057 .or_else(|| body.get("error"))
2058 .and_then(Value::as_str)
2059 .unwrap_or("workflow query was rejected")
2060 .to_string();
2061
2062 QueryFailure {
2063 status: status.as_u16(),
2064 reason,
2065 message,
2066 body,
2067 }
2068}
2069
2070fn workflow_command_result(
2071 command: WorkflowCommandKind,
2072 data: Value,
2073 workflow_id: &str,
2074 run_id: Option<&str>,
2075) -> WorkflowCommandResult {
2076 WorkflowCommandResult {
2077 command,
2078 workflow_id: data
2079 .get("workflow_id")
2080 .and_then(Value::as_str)
2081 .unwrap_or(workflow_id)
2082 .to_string(),
2083 run_id: data
2084 .get("run_id")
2085 .and_then(Value::as_str)
2086 .or(run_id)
2087 .map(str::to_string),
2088 outcome: data
2089 .get("outcome")
2090 .and_then(Value::as_str)
2091 .map(str::to_string),
2092 reason: data
2093 .get("reason")
2094 .and_then(Value::as_str)
2095 .map(str::to_string),
2096 command_status: data
2097 .get("command_status")
2098 .and_then(Value::as_str)
2099 .map(str::to_string),
2100 raw: data,
2101 }
2102}
2103
2104fn workflow_command_rejection(
2105 command: WorkflowCommandKind,
2106 status: reqwest::StatusCode,
2107 raw_body: String,
2108 workflow_id: &str,
2109 run_id: Option<&str>,
2110) -> WorkflowCommandRejection {
2111 let body = serde_json::from_str(&raw_body).unwrap_or_else(|_| json!({"message": raw_body}));
2112 WorkflowCommandRejection {
2113 command,
2114 status: status.as_u16(),
2115 reason: body
2116 .get("reason")
2117 .and_then(Value::as_str)
2118 .unwrap_or("workflow_command_rejected")
2119 .to_string(),
2120 message: body
2121 .get("message")
2122 .or_else(|| body.get("error"))
2123 .and_then(Value::as_str)
2124 .unwrap_or("workflow lifecycle command was rejected")
2125 .to_string(),
2126 workflow_id: body
2127 .get("workflow_id")
2128 .and_then(Value::as_str)
2129 .unwrap_or(workflow_id)
2130 .to_string(),
2131 run_id: body
2132 .get("run_id")
2133 .and_then(Value::as_str)
2134 .or(run_id)
2135 .map(str::to_string),
2136 target_scope: body
2137 .get("target_scope")
2138 .and_then(Value::as_str)
2139 .map(str::to_string),
2140 body,
2141 }
2142}
2143
2144fn query_task_response(response: Result<Value>) -> Result<Value> {
2145 match response {
2146 Err(Error::Http { status, body }) => Err(Error::QueryFailed(query_failure(status, body))),
2147 response => response,
2148 }
2149}
2150
2151fn worker_poll_response<T: DeserializeOwned>(response: Result<T>) -> Result<T> {
2152 match response {
2153 Err(Error::Http { status, body })
2154 if status == reqwest::StatusCode::CONFLICT && worker_poll_body_is_stop(&body) =>
2155 {
2156 Ok(serde_json::from_str(&body)?)
2157 }
2158 response => response,
2159 }
2160}
2161
2162fn worker_poll_body_is_stop(body: &str) -> bool {
2163 serde_json::from_str::<Value>(body)
2164 .ok()
2165 .is_some_and(|body| {
2166 worker_poll_is_stop(
2167 body.get("poll_status").and_then(Value::as_str),
2168 body.get("reason").and_then(Value::as_str),
2169 )
2170 })
2171}
2172
2173fn worker_poll_is_stop(poll_status: Option<&str>, reason: Option<&str>) -> bool {
2174 matches!(poll_status, Some("draining" | "stopped"))
2175 || matches!(reason, Some("worker_draining" | "worker_stopped"))
2176}
2177
2178fn query_task_rejection_is_final(error: &Error) -> bool {
2179 matches!(
2180 error,
2181 Error::QueryFailed(failure)
2182 if QUERY_TASK_FINAL_REJECTION_REASONS.contains(&failure.reason.as_str())
2183 )
2184}
2185
2186fn activity_task_response<T>(
2187 response: Result<T>,
2188 operation: &str,
2189 task_id: &str,
2190 activity_attempt_id: &str,
2191) -> Result<T> {
2192 match response {
2193 Err(Error::Http { status, body }) => {
2194 let body = serde_json::from_str(&body).unwrap_or_else(|_| json!({"message": body}));
2195 Err(Error::ActivityTaskRejected(ActivityTaskRejection {
2196 operation: operation.to_string(),
2197 status: status.as_u16(),
2198 reason: body
2199 .get("reason")
2200 .and_then(Value::as_str)
2201 .unwrap_or("activity_task_rejected")
2202 .to_string(),
2203 task_id: body
2204 .get("task_id")
2205 .and_then(Value::as_str)
2206 .unwrap_or(task_id)
2207 .to_string(),
2208 activity_attempt_id: body
2209 .get("activity_attempt_id")
2210 .and_then(Value::as_str)
2211 .unwrap_or(activity_attempt_id)
2212 .to_string(),
2213 cancel_requested: body
2214 .get("cancel_requested")
2215 .and_then(Value::as_bool)
2216 .unwrap_or(false),
2217 can_continue: body.get("can_continue").and_then(Value::as_bool),
2218 run_closed_reason: body
2219 .get("run_closed_reason")
2220 .and_then(Value::as_str)
2221 .map(str::to_string),
2222 body,
2223 }))
2224 }
2225 response => response,
2226 }
2227}
2228
2229fn activity_task_rejection_is_final(error: &Error) -> bool {
2230 matches!(
2231 error,
2232 Error::ActivityTaskRejected(rejection)
2233 if matches!(
2234 rejection.reason.as_str(),
2235 "run_cancelled"
2236 | "run_terminated"
2237 | "attempt_closed"
2238 | "stale_attempt"
2239 | "activity_cancelled"
2240 | "task_cancelled"
2241 | "run_closed"
2242 | "activity_not_running"
2243 | "attempt_not_found"
2244 )
2245 )
2246}
2247
2248fn workflow_task_completion_is_terminal_timeout(
2249 error: &Error,
2250 task_id: &str,
2251 workflow_task_attempt: u64,
2252 run_id: Option<&str>,
2253) -> bool {
2254 let Error::Http { status, body } = error else {
2255 return false;
2256 };
2257 if *status != reqwest::StatusCode::CONFLICT {
2258 return false;
2259 }
2260
2261 let Some(run_id) = run_id else {
2262 return false;
2263 };
2264 let Ok(body) = serde_json::from_str::<Value>(body) else {
2265 return false;
2266 };
2267
2268 body.get("recorded").and_then(Value::as_bool) == Some(false)
2269 && body.get("reason").and_then(Value::as_str) == Some("run_timed_out")
2270 && body.get("run_status").and_then(Value::as_str) == Some("failed")
2271 && body.get("run_id").and_then(Value::as_str) == Some(run_id)
2272 && body.get("task_id").and_then(Value::as_str) == Some(task_id)
2273 && body.get("workflow_task_attempt").and_then(Value::as_u64) == Some(workflow_task_attempt)
2274}
2275
2276fn protocol_failure(status: reqwest::StatusCode, raw_body: &str) -> Option<ProtocolFailure> {
2277 let body: Value = serde_json::from_str(raw_body).ok()?;
2278 let reason = body.get("reason")?.as_str()?;
2279 if !matches!(
2280 reason,
2281 "missing_protocol_version"
2282 | "unsupported_protocol_version"
2283 | "missing_control_plane_version"
2284 | "unsupported_control_plane_version"
2285 ) {
2286 return None;
2287 }
2288
2289 Some(ProtocolFailure {
2290 status: status.as_u16(),
2291 reason: reason.to_string(),
2292 message: body
2293 .get("message")
2294 .or_else(|| body.get("error"))
2295 .and_then(Value::as_str)
2296 .unwrap_or("protocol version rejected")
2297 .to_string(),
2298 supported_version: body
2299 .get("supported_version")
2300 .and_then(Value::as_str)
2301 .map(str::to_string),
2302 requested_version: body
2303 .get("requested_version")
2304 .and_then(Value::as_str)
2305 .map(str::to_string),
2306 body,
2307 })
2308}
2309
2310fn long_poll_timeout_seconds(timeout: Duration) -> u64 {
2311 timeout
2312 .as_secs()
2313 .saturating_add(u64::from(timeout.subsec_nanos() > 0))
2314 .min(MAX_LONG_POLL_TIMEOUT_SECONDS)
2315}
2316
2317fn worker_operation_is_retryable(error: &Error) -> bool {
2318 match error {
2319 Error::Transport(error) => {
2320 error.is_timeout() || error.is_connect() || error.is_request() || error.is_body()
2321 }
2322 Error::Http { status, .. } => {
2323 matches!(
2324 *status,
2325 reqwest::StatusCode::REQUEST_TIMEOUT | reqwest::StatusCode::TOO_MANY_REQUESTS
2326 ) || status.is_server_error()
2327 }
2328 _ => false,
2329 }
2330}
2331
2332fn worker_retry_delay(policy: WorkerRetryPolicy, retry: usize) -> Duration {
2333 let exponent = retry.saturating_sub(1).min(31) as u32;
2334 policy
2335 .initial_backoff
2336 .saturating_mul(1_u32 << exponent)
2337 .min(policy.max_backoff)
2338}
2339
2340#[derive(Debug)]
2341pub struct ClientBuilder {
2342 base_url: String,
2343 token: Option<String>,
2344 control_token: Option<String>,
2345 worker_token: Option<String>,
2346 namespace: String,
2347 timeout: Duration,
2348}
2349
2350impl ClientBuilder {
2351 pub fn token(mut self, token: Option<String>) -> Self {
2352 self.token = token;
2353 self
2354 }
2355
2356 pub fn control_token(mut self, token: Option<String>) -> Self {
2357 self.control_token = token;
2358 self
2359 }
2360
2361 pub fn worker_token(mut self, token: Option<String>) -> Self {
2362 self.worker_token = token;
2363 self
2364 }
2365
2366 pub fn namespace(mut self, namespace: impl Into<String>) -> Self {
2367 self.namespace = namespace.into();
2368 self
2369 }
2370
2371 pub fn timeout(mut self, timeout: Duration) -> Self {
2372 self.timeout = timeout;
2373 self
2374 }
2375
2376 pub fn build(self) -> Result<Client> {
2377 Ok(Client {
2378 http: reqwest::Client::builder().timeout(self.timeout).build()?,
2379 base_url: self.base_url.trim_end_matches('/').to_string(),
2380 token: self.token,
2381 control_token: self.control_token,
2382 worker_token: self.worker_token,
2383 namespace: self.namespace,
2384 })
2385 }
2386}
2387
2388#[derive(Clone, Debug)]
2389pub struct WorkflowHandle {
2390 client: Client,
2391 pub workflow_id: String,
2392 pub run_id: Option<String>,
2393 pub workflow_type: String,
2394}
2395
2396impl WorkflowHandle {
2397 pub async fn describe(&self) -> Result<WorkflowDescription> {
2399 self.client.describe_workflow(&self.workflow_id).await
2400 }
2401
2402 pub async fn describe_selected_run(&self) -> Result<WorkflowDescription> {
2404 let run_id = self.run_id.as_deref().ok_or_else(|| {
2405 Error::Codec("run_id is required for selected-run description".to_string())
2406 })?;
2407 self.client
2408 .describe_workflow_run(&self.workflow_id, run_id)
2409 .await
2410 }
2411
2412 pub async fn signal<T: Serialize>(&self, signal_name: &str, input: T) -> Result<Value> {
2413 self.client
2414 .signal_workflow(&self.workflow_id, signal_name, input)
2415 .await
2416 }
2417
2418 pub async fn signal_selected_run<T: Serialize>(
2420 &self,
2421 signal_name: &str,
2422 input: T,
2423 ) -> Result<Value> {
2424 let run_id = self.run_id.as_deref().ok_or_else(|| {
2425 Error::Codec("run_id is required for selected-run signaling".to_string())
2426 })?;
2427 self.client
2428 .signal_workflow_run(&self.workflow_id, run_id, signal_name, input)
2429 .await
2430 }
2431
2432 pub async fn cancel(&self, options: WorkflowCommandOptions) -> Result<WorkflowCommandResult> {
2434 self.client
2435 .cancel_workflow(&self.workflow_id, options)
2436 .await
2437 }
2438
2439 pub async fn cancel_selected_run(
2441 &self,
2442 options: WorkflowCommandOptions,
2443 ) -> Result<WorkflowCommandResult> {
2444 let run_id = self.run_id.as_deref().ok_or_else(|| {
2445 Error::Codec("run_id is required for selected-run cancellation".to_string())
2446 })?;
2447 self.client
2448 .cancel_workflow_run(&self.workflow_id, run_id, options)
2449 .await
2450 }
2451
2452 pub async fn terminate(
2454 &self,
2455 options: WorkflowCommandOptions,
2456 ) -> Result<WorkflowCommandResult> {
2457 self.client
2458 .terminate_workflow(&self.workflow_id, options)
2459 .await
2460 }
2461
2462 pub async fn terminate_selected_run(
2464 &self,
2465 options: WorkflowCommandOptions,
2466 ) -> Result<WorkflowCommandResult> {
2467 let run_id = self.run_id.as_deref().ok_or_else(|| {
2468 Error::Codec("run_id is required for selected-run termination".to_string())
2469 })?;
2470 self.client
2471 .terminate_workflow_run(&self.workflow_id, run_id, options)
2472 .await
2473 }
2474
2475 pub async fn query<T: Serialize>(&self, query_name: &str, input: T) -> Result<Value> {
2477 self.client
2478 .query_workflow(&self.workflow_id, query_name, input)
2479 .await
2480 }
2481
2482 pub async fn query_selected_run<T: Serialize>(
2484 &self,
2485 query_name: &str,
2486 input: T,
2487 ) -> Result<Value> {
2488 let run_id = self
2489 .run_id
2490 .as_deref()
2491 .ok_or_else(|| Error::Codec("run_id is required for selected-run query".to_string()))?;
2492 self.client
2493 .query_workflow_run(&self.workflow_id, run_id, query_name, input)
2494 .await
2495 }
2496
2497 pub async fn result(&self, options: WorkflowResultOptions) -> Result<Value> {
2499 self.result_target(options, None).await
2500 }
2501
2502 pub async fn result_selected_run(&self, options: WorkflowResultOptions) -> Result<Value> {
2504 let run_id = self.run_id.as_deref().ok_or_else(|| {
2505 Error::Codec("run_id is required for selected-run result".to_string())
2506 })?;
2507 self.result_target(options, Some(run_id)).await
2508 }
2509
2510 async fn result_target(
2511 &self,
2512 options: WorkflowResultOptions,
2513 selected_run_id: Option<&str>,
2514 ) -> Result<Value> {
2515 let started = Instant::now();
2516
2517 loop {
2518 let description = match selected_run_id {
2519 Some(run_id) => {
2520 self.client
2521 .describe_workflow_run(&self.workflow_id, run_id)
2522 .await?
2523 }
2524 None => self.describe().await?,
2525 };
2526 if description.is_completed() {
2527 return Ok(description.output.unwrap_or(Value::Null));
2528 }
2529
2530 if description.is_terminal() {
2531 let outcome =
2532 workflow_terminal_outcome(&description, &self.workflow_id, selected_run_id);
2533 return Err(match outcome.kind {
2534 WorkflowTerminalKind::Failed => Error::WorkflowFailed(outcome),
2535 WorkflowTerminalKind::Cancelled => Error::WorkflowCancelled(outcome),
2536 WorkflowTerminalKind::Terminated => Error::WorkflowTerminated(outcome),
2537 WorkflowTerminalKind::TimedOut => Error::WorkflowTimedOut(outcome),
2538 });
2539 }
2540
2541 if started.elapsed() >= options.timeout {
2542 return Err(Error::WorkflowTimedOut(WorkflowTerminalOutcome {
2543 kind: WorkflowTerminalKind::TimedOut,
2544 workflow_id: description
2545 .workflow_id
2546 .clone()
2547 .unwrap_or_else(|| self.workflow_id.clone()),
2548 run_id: description
2549 .run_id
2550 .clone()
2551 .or_else(|| selected_run_id.map(str::to_string)),
2552 reason: "result_wait_timeout".to_string(),
2553 failure_category: Some("client_timeout".to_string()),
2554 failure_id: None,
2555 exception_type: None,
2556 exception_class: None,
2557 non_retryable: None,
2558 message: Some(format!(
2559 "workflow result was not terminal within {:?}",
2560 options.timeout
2561 )),
2562 exception: None,
2563 raw: description.raw_value(),
2564 }));
2565 }
2566
2567 tokio::time::sleep(options.poll_interval).await;
2568 }
2569 }
2570}
2571
2572#[derive(Clone, Copy, Debug)]
2573pub struct WorkflowResultOptions {
2574 pub poll_interval: Duration,
2575 pub timeout: Duration,
2576}
2577
2578impl Default for WorkflowResultOptions {
2579 fn default() -> Self {
2580 Self {
2581 poll_interval: Duration::from_millis(500),
2582 timeout: Duration::from_secs(30),
2583 }
2584 }
2585}
2586
2587#[derive(Clone, Debug, Deserialize)]
2588pub struct WorkflowDescription {
2589 pub workflow_id: Option<String>,
2590 pub run_id: Option<String>,
2591 pub workflow_type: Option<String>,
2592 pub status: Option<String>,
2593 #[serde(default)]
2594 pub closed_reason: Option<String>,
2595 #[serde(default)]
2596 pub error: Option<String>,
2597 #[serde(default)]
2598 pub failure: Option<Value>,
2599 #[serde(default)]
2600 pub exception: Option<Value>,
2601 #[serde(default)]
2602 pub failures: Vec<Value>,
2603 #[serde(default)]
2604 pub output: Option<Value>,
2605 #[serde(default)]
2606 pub output_envelope: Option<Value>,
2607 #[serde(flatten)]
2608 pub raw: HashMap<String, Value>,
2609}
2610
2611impl WorkflowDescription {
2612 pub fn is_completed(&self) -> bool {
2613 matches!(self.status.as_deref(), Some("completed" | "Completed"))
2614 }
2615
2616 pub fn is_terminal(&self) -> bool {
2617 matches!(
2618 self.status.as_deref(),
2619 Some(
2620 "completed"
2621 | "Completed"
2622 | "failed"
2623 | "Failed"
2624 | "cancelled"
2625 | "Cancelled"
2626 | "terminated"
2627 | "Terminated"
2628 | "timed_out"
2629 | "TimedOut",
2630 )
2631 )
2632 }
2633
2634 fn decode_payloads(&mut self) -> Result<()> {
2635 if let Some(envelope) = &self.output_envelope {
2636 self.output = Some(decode_wire_value(envelope, DEFAULT_CODEC)?);
2637 }
2638
2639 Ok(())
2640 }
2641
2642 fn raw_value(&self) -> Value {
2643 let mut data = self.raw.clone();
2644 data.insert(
2645 "workflow_id".to_string(),
2646 self.workflow_id
2647 .clone()
2648 .map(Value::String)
2649 .unwrap_or(Value::Null),
2650 );
2651 data.insert(
2652 "run_id".to_string(),
2653 self.run_id
2654 .clone()
2655 .map(Value::String)
2656 .unwrap_or(Value::Null),
2657 );
2658 data.insert(
2659 "workflow_type".to_string(),
2660 self.workflow_type
2661 .clone()
2662 .map(Value::String)
2663 .unwrap_or(Value::Null),
2664 );
2665 data.insert(
2666 "status".to_string(),
2667 self.status
2668 .clone()
2669 .map(Value::String)
2670 .unwrap_or(Value::Null),
2671 );
2672 data.insert(
2673 "closed_reason".to_string(),
2674 self.closed_reason
2675 .clone()
2676 .map(Value::String)
2677 .unwrap_or(Value::Null),
2678 );
2679 if let Some(failure) = &self.failure {
2680 data.insert("failure".to_string(), failure.clone());
2681 }
2682 if let Some(exception) = &self.exception {
2683 data.insert("exception".to_string(), exception.clone());
2684 }
2685 Value::Object(data.into_iter().collect())
2686 }
2687}
2688
2689fn workflow_terminal_outcome(
2690 description: &WorkflowDescription,
2691 workflow_id: &str,
2692 run_id: Option<&str>,
2693) -> WorkflowTerminalOutcome {
2694 let terminal_kind = description
2695 .closed_reason
2696 .as_deref()
2697 .or(description.status.as_deref())
2698 .unwrap_or("failed")
2699 .to_ascii_lowercase();
2700 let kind = match terminal_kind.as_str() {
2701 "cancelled" | "canceled" => WorkflowTerminalKind::Cancelled,
2702 "terminated" => WorkflowTerminalKind::Terminated,
2703 "timed_out" | "timedout" => WorkflowTerminalKind::TimedOut,
2704 _ => WorkflowTerminalKind::Failed,
2705 };
2706 let default_reason = match kind {
2707 WorkflowTerminalKind::Failed => "workflow_failed",
2708 WorkflowTerminalKind::Cancelled => "cancelled",
2709 WorkflowTerminalKind::Terminated => "terminated",
2710 WorkflowTerminalKind::TimedOut => "timed_out",
2711 };
2712 let failure = description
2713 .failure
2714 .as_ref()
2715 .filter(|value| value.is_object());
2716 let nested_failure = failure
2717 .and_then(|value| value.get("failures"))
2718 .and_then(Value::as_array)
2719 .and_then(|failures| failures.last())
2720 .or_else(|| description.failures.last());
2721 let exception = description
2722 .exception
2723 .clone()
2724 .or_else(|| failure.and_then(|value| value.get("exception")).cloned())
2725 .or_else(|| {
2726 nested_failure
2727 .and_then(|value| value.get("exception_payload"))
2728 .cloned()
2729 });
2730 let string_field = |name: &str| {
2731 failure
2732 .and_then(|value| value.get(name))
2733 .and_then(Value::as_str)
2734 .or_else(|| {
2735 nested_failure
2736 .and_then(|value| value.get(name))
2737 .and_then(Value::as_str)
2738 })
2739 .map(str::to_string)
2740 };
2741 let exception_field = |name: &str| {
2742 exception
2743 .as_ref()
2744 .and_then(|value| value.get(name))
2745 .and_then(Value::as_str)
2746 .map(str::to_string)
2747 };
2748 let message = description
2749 .error
2750 .clone()
2751 .or_else(|| string_field("message"))
2752 .or_else(|| exception_field("message"));
2753 let reason = description
2754 .raw
2755 .get("reason")
2756 .and_then(Value::as_str)
2757 .map(str::to_string)
2758 .or_else(|| {
2759 failure
2760 .and_then(|value| value.get("reason"))
2761 .and_then(Value::as_str)
2762 .map(str::to_string)
2763 })
2764 .or_else(|| description.closed_reason.clone())
2765 .unwrap_or_else(|| default_reason.to_string());
2766 let failure_id = string_field("failure_id").or_else(|| {
2767 nested_failure
2768 .and_then(|value| value.get("id"))
2769 .and_then(Value::as_str)
2770 .map(str::to_string)
2771 });
2772
2773 WorkflowTerminalOutcome {
2774 kind,
2775 workflow_id: description
2776 .workflow_id
2777 .clone()
2778 .unwrap_or_else(|| workflow_id.to_string()),
2779 run_id: description
2780 .run_id
2781 .clone()
2782 .or_else(|| run_id.map(str::to_string)),
2783 reason,
2784 failure_category: string_field("failure_category")
2785 .or_else(|| Some(default_reason.to_string())),
2786 failure_id,
2787 exception_type: string_field("exception_type").or_else(|| exception_field("type")),
2788 exception_class: string_field("exception_class").or_else(|| exception_field("class")),
2789 non_retryable: failure
2790 .and_then(|value| value.get("non_retryable"))
2791 .and_then(Value::as_bool)
2792 .or_else(|| {
2793 nested_failure
2794 .and_then(|value| value.get("non_retryable"))
2795 .and_then(Value::as_bool)
2796 }),
2797 message,
2798 exception,
2799 raw: description.raw_value(),
2800 }
2801}
2802
2803#[derive(Clone, Debug, Deserialize)]
2804pub struct RegisterWorkerResponse {
2805 pub worker_id: String,
2806 pub registered: bool,
2807 #[serde(default)]
2808 pub heartbeat_interval_seconds: Option<u64>,
2809 #[serde(default)]
2810 pub protocol_version: Option<String>,
2811 #[serde(default)]
2812 pub server_capabilities: Option<Value>,
2813}
2814
2815#[derive(Clone, Debug, Deserialize)]
2816pub struct PollWorkflowTaskResponse {
2817 #[serde(default)]
2818 pub task: Option<WorkflowTask>,
2819 #[serde(default)]
2820 pub poll_status: Option<String>,
2821 #[serde(default)]
2822 pub reason: Option<String>,
2823 #[serde(default)]
2824 pub protocol_version: Option<String>,
2825 #[serde(default)]
2826 pub server_capabilities: Option<Value>,
2827}
2828
2829impl PollWorkflowTaskResponse {
2830 pub fn outcome(&self) -> WorkerPollOutcome {
2832 worker_poll_outcome(
2833 self.task.is_some(),
2834 self.poll_status.as_deref(),
2835 self.reason.as_deref(),
2836 )
2837 }
2838}
2839
2840#[derive(Clone, Debug, Deserialize)]
2841pub struct PollActivityTaskResponse {
2842 #[serde(default)]
2843 pub task: Option<ActivityTask>,
2844 #[serde(default)]
2845 pub poll_status: Option<String>,
2846 #[serde(default)]
2847 pub reason: Option<String>,
2848}
2849
2850impl PollActivityTaskResponse {
2851 pub fn outcome(&self) -> WorkerPollOutcome {
2853 worker_poll_outcome(
2854 self.task.is_some(),
2855 self.poll_status.as_deref(),
2856 self.reason.as_deref(),
2857 )
2858 }
2859}
2860
2861#[derive(Clone, Debug, Deserialize)]
2862pub struct PollQueryTaskResponse {
2863 #[serde(default)]
2864 pub task: Option<QueryTask>,
2865 #[serde(default)]
2866 pub poll_status: Option<String>,
2867 #[serde(default)]
2868 pub reason: Option<String>,
2869}
2870
2871impl PollQueryTaskResponse {
2872 pub fn outcome(&self) -> WorkerPollOutcome {
2874 worker_poll_outcome(
2875 self.task.is_some(),
2876 self.poll_status.as_deref(),
2877 self.reason.as_deref(),
2878 )
2879 }
2880}
2881
2882#[derive(Clone, Debug, PartialEq, Eq)]
2884pub enum WorkerPollOutcome {
2885 Task,
2887 Idle {
2889 poll_status: Option<String>,
2890 reason: Option<String>,
2891 },
2892 Stop {
2894 poll_status: Option<String>,
2895 reason: Option<String>,
2896 },
2897}
2898
2899impl WorkerPollOutcome {
2900 pub fn should_stop(&self) -> bool {
2901 matches!(self, Self::Stop { .. })
2902 }
2903}
2904
2905fn worker_poll_outcome(
2906 has_task: bool,
2907 poll_status: Option<&str>,
2908 reason: Option<&str>,
2909) -> WorkerPollOutcome {
2910 if worker_poll_is_stop(poll_status, reason) {
2911 return WorkerPollOutcome::Stop {
2912 poll_status: poll_status.map(str::to_string),
2913 reason: reason.map(str::to_string),
2914 };
2915 }
2916
2917 if has_task {
2918 WorkerPollOutcome::Task
2919 } else {
2920 WorkerPollOutcome::Idle {
2921 poll_status: poll_status.map(str::to_string),
2922 reason: reason.map(str::to_string),
2923 }
2924 }
2925}
2926
2927#[derive(Clone, Debug, Deserialize)]
2929pub struct QueryTask {
2930 pub query_task_id: String,
2931 #[serde(default = "default_workflow_task_attempt")]
2932 pub query_task_attempt: u64,
2933 #[serde(default)]
2934 pub lease_owner: Option<String>,
2935 #[serde(default)]
2936 pub workflow_id: Option<String>,
2937 #[serde(default)]
2938 pub run_id: Option<String>,
2939 pub workflow_type: String,
2940 pub query_name: String,
2941 #[serde(default = "default_payload_codec")]
2942 pub payload_codec: String,
2943 #[serde(default)]
2944 pub workflow_arguments: Option<Value>,
2945 #[serde(default)]
2946 pub query_arguments: Option<Value>,
2947 #[serde(default)]
2948 pub history_events: Vec<HistoryEvent>,
2949 #[serde(default)]
2950 pub history_export: Option<Value>,
2951 #[serde(default)]
2952 pub run_status: Option<String>,
2953}
2954
2955#[derive(Clone, Debug, Deserialize)]
2956pub struct WorkflowTask {
2957 pub task_id: String,
2958 #[serde(default)]
2959 pub workflow_id: Option<String>,
2960 #[serde(default)]
2961 pub run_id: Option<String>,
2962 pub workflow_type: String,
2963 #[serde(default = "default_payload_codec")]
2964 pub payload_codec: String,
2965 #[serde(default)]
2966 pub arguments: Option<Value>,
2967 #[serde(default)]
2968 pub history_events: Vec<HistoryEvent>,
2969 #[serde(default)]
2970 pub total_history_events: Option<u64>,
2971 #[serde(default)]
2972 pub history_size_bytes: Option<u64>,
2973 #[serde(default)]
2974 pub continue_as_new_recommended: Option<bool>,
2975 #[serde(default)]
2976 pub history_budget_pressure: Option<String>,
2977 #[serde(default)]
2978 pub next_history_page_token: Option<String>,
2979 #[serde(default = "default_workflow_task_attempt")]
2980 pub workflow_task_attempt: u64,
2981 #[serde(default)]
2982 pub workflow_signal_id: Option<String>,
2983 #[serde(default)]
2984 pub signal_name: Option<String>,
2985 #[serde(default)]
2986 pub signal_arguments: Option<Value>,
2987 #[serde(default)]
2988 pub lease_owner: Option<String>,
2989}
2990
2991impl WorkflowTask {
2992 fn append_history_page(&mut self, page: WorkflowTaskHistoryPage) {
2993 self.history_events.extend(page.history_events);
2994
2995 if page.total_history_events.is_some() {
2996 self.total_history_events = page.total_history_events;
2997 }
2998
2999 self.next_history_page_token = page
3000 .next_history_page_token
3001 .filter(|token| !token.is_empty());
3002 }
3003}
3004
3005#[derive(Clone, Debug, Deserialize)]
3006struct WorkflowTaskHistoryPage {
3007 #[serde(default)]
3008 history_events: Vec<HistoryEvent>,
3009 #[serde(default)]
3010 total_history_events: Option<u64>,
3011 #[serde(default)]
3012 next_history_page_token: Option<String>,
3013}
3014
3015#[derive(Clone, Debug, Deserialize)]
3016pub struct ActivityTask {
3017 pub task_id: String,
3018 #[serde(default)]
3019 pub activity_attempt_id: Option<String>,
3020 #[serde(default)]
3021 pub attempt_id: Option<String>,
3022 pub activity_type: String,
3023 #[serde(default = "default_payload_codec")]
3024 pub payload_codec: String,
3025 #[serde(default)]
3026 pub arguments: Option<Value>,
3027 #[serde(default = "default_attempt_number")]
3028 pub attempt_number: u64,
3029 #[serde(default)]
3030 pub lease_owner: Option<String>,
3031}
3032
3033#[derive(Clone, Debug, Deserialize)]
3034pub struct HistoryEvent {
3035 #[serde(alias = "type")]
3036 pub event_type: String,
3037 #[serde(default)]
3038 pub payload: Value,
3039 #[serde(flatten)]
3040 pub raw: HashMap<String, Value>,
3041}
3042
3043#[derive(Clone, Debug, PartialEq)]
3045pub struct QuerySignal {
3046 pub id: Option<String>,
3047 pub name: String,
3048 pub arguments: Vec<Value>,
3049 pub workflow_sequence: Option<u64>,
3050}
3051
3052#[derive(Clone, Debug)]
3058pub struct QueryContext {
3059 pub workflow_id: Option<String>,
3060 pub run_id: Option<String>,
3061 pub workflow_type: String,
3062 pub run_status: Option<String>,
3063 workflow_input: Value,
3064 history_events: Arc<Vec<HistoryEvent>>,
3065 signal_events: Arc<Vec<QuerySignal>>,
3066}
3067
3068impl QueryContext {
3069 pub fn workflow_input(&self) -> &Value {
3071 &self.workflow_input
3072 }
3073
3074 pub fn history_events(&self) -> &[HistoryEvent] {
3076 self.history_events.as_slice()
3077 }
3078
3079 pub fn signal_events(&self) -> &[QuerySignal] {
3081 self.signal_events.as_slice()
3082 }
3083
3084 pub fn signals(&self, signal_name: &str) -> Vec<Vec<Value>> {
3086 self.signal_events
3087 .iter()
3088 .filter(|signal| signal.name == signal_name)
3089 .map(|signal| signal.arguments.clone())
3090 .collect()
3091 }
3092}
3093
3094#[derive(Clone, Debug, Deserialize)]
3095pub struct ActivityHeartbeatResponse {
3096 #[serde(default)]
3097 pub cancel_requested: bool,
3098 #[serde(default)]
3099 pub heartbeat_recorded: bool,
3100 #[serde(default)]
3101 pub can_continue: Option<bool>,
3102 #[serde(default)]
3103 pub reason: Option<String>,
3104 #[serde(default)]
3105 pub run_closed_reason: Option<String>,
3106 #[serde(default)]
3107 pub run_closed_at: Option<String>,
3108 #[serde(default)]
3109 pub lease_expires_at: Option<String>,
3110 #[serde(default)]
3111 pub last_heartbeat_at: Option<String>,
3112}
3113
3114impl ActivityHeartbeatResponse {
3115 pub fn should_stop(&self) -> bool {
3117 self.cancel_requested || self.can_continue == Some(false)
3118 }
3119}
3120
3121fn default_payload_codec() -> String {
3122 DEFAULT_CODEC.to_string()
3123}
3124
3125fn default_workflow_task_attempt() -> u64 {
3126 1
3127}
3128
3129fn default_attempt_number() -> u64 {
3130 1
3131}
3132
3133type WorkflowFuture = Pin<Box<dyn Future<Output = Result<Value>> + Send + 'static>>;
3134type WorkflowHandler = Arc<dyn Fn(WorkflowContext, Value) -> WorkflowFuture + Send + Sync>;
3135type ErasedWorkflowState = Arc<dyn Any + Send + Sync>;
3136type WorkflowStateSnapshot = Arc<dyn Fn() -> Result<ErasedWorkflowState> + Send + Sync>;
3137type ReplayedWorkflowHandler =
3138 Arc<dyn Fn(WorkflowContext, Value) -> ReplayedWorkflowInvocation + Send + Sync>;
3139type ActivityFuture = Pin<Box<dyn Future<Output = Result<Value>> + Send + 'static>>;
3140type ActivityHandler = Arc<dyn Fn(ActivityContext, Value) -> ActivityFuture + Send + Sync>;
3141type QueryFuture = Pin<Box<dyn Future<Output = Result<Value>> + Send + 'static>>;
3142type QueryHandler = Arc<dyn Fn(QueryContext, Value) -> QueryFuture + Send + Sync>;
3143type ReplayedQueryHandler = Arc<
3144 dyn Fn(QueryContext, ErasedWorkflowState, Value) -> std::result::Result<QueryFuture, String>
3145 + Send
3146 + Sync,
3147>;
3148type WorkerHeartbeatObserver = Arc<dyn Fn(&WorkerHeartbeatObservation) + Send + Sync>;
3149
3150struct ReplayedWorkflowInvocation {
3151 future: WorkflowFuture,
3152 snapshot: WorkflowStateSnapshot,
3153}
3154
3155#[derive(Clone)]
3156struct RegisteredWorkflow {
3157 execute: WorkflowHandler,
3158 replay: Option<ReplayedWorkflowHandler>,
3159 state_type: Option<TypeId>,
3160}
3161
3162#[derive(Clone)]
3163enum RegisteredQuery {
3164 Snapshot(QueryHandler),
3165 Replayed {
3166 state_type: TypeId,
3167 handler: ReplayedQueryHandler,
3168 },
3169}
3170
3171#[derive(Clone, Debug)]
3172pub struct WorkerHeartbeatObservation {
3173 pub worker_id: String,
3174 pub task_queue: String,
3175 pub acknowledged_at_unix_millis: u64,
3176 pub acknowledgement: Value,
3177}
3178
3179#[derive(Clone, Copy, Debug)]
3186pub struct WorkerRetryPolicy {
3187 pub max_retries: usize,
3189 pub initial_backoff: Duration,
3191 pub max_backoff: Duration,
3193}
3194
3195impl Default for WorkerRetryPolicy {
3196 fn default() -> Self {
3197 Self {
3198 max_retries: 5,
3199 initial_backoff: Duration::from_millis(100),
3200 max_backoff: Duration::from_secs(5),
3201 }
3202 }
3203}
3204
3205#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3206enum ManagedPollOutcome {
3207 Idle,
3208 Handled,
3209 Stop,
3210}
3211
3212#[derive(Clone)]
3213pub struct Worker {
3214 client: Client,
3215 worker_id: String,
3216 task_queue: String,
3217 workflows: HashMap<String, RegisteredWorkflow>,
3218 activities: HashMap<String, ActivityHandler>,
3219 queries: HashMap<String, HashMap<String, RegisteredQuery>>,
3220 max_concurrent_workflow_tasks: usize,
3221 max_concurrent_activity_tasks: usize,
3222 poll_timeout: Duration,
3223 heartbeat_interval: Duration,
3224 retry_policy: WorkerRetryPolicy,
3225 heartbeat_observer: Option<WorkerHeartbeatObserver>,
3226}
3227
3228impl Worker {
3229 pub fn new(client: Client, task_queue: impl Into<String>) -> Self {
3230 Self {
3231 client,
3232 worker_id: default_worker_id(),
3233 task_queue: task_queue.into(),
3234 workflows: HashMap::new(),
3235 activities: HashMap::new(),
3236 queries: HashMap::new(),
3237 max_concurrent_workflow_tasks: 10,
3238 max_concurrent_activity_tasks: 10,
3239 poll_timeout: Duration::from_secs(30),
3240 heartbeat_interval: Duration::from_secs(60),
3241 retry_policy: WorkerRetryPolicy::default(),
3242 heartbeat_observer: None,
3243 }
3244 }
3245
3246 pub fn worker_id(mut self, worker_id: impl Into<String>) -> Self {
3247 self.worker_id = worker_id.into();
3248 self
3249 }
3250
3251 pub fn poll_timeout(mut self, timeout: Duration) -> Self {
3252 self.poll_timeout = timeout;
3253 self
3254 }
3255
3256 pub fn heartbeat_interval(mut self, interval: Duration) -> Self {
3257 self.heartbeat_interval = interval;
3258 self
3259 }
3260
3261 pub fn retry_policy(mut self, policy: WorkerRetryPolicy) -> Self {
3263 self.retry_policy = policy;
3264 self
3265 }
3266
3267 pub fn on_worker_heartbeat<F>(mut self, observer: F) -> Self
3268 where
3269 F: Fn(&WorkerHeartbeatObservation) + Send + Sync + 'static,
3270 {
3271 self.heartbeat_observer = Some(Arc::new(observer));
3272 self
3273 }
3274
3275 pub fn max_concurrent_workflow_tasks(mut self, count: usize) -> Self {
3276 self.max_concurrent_workflow_tasks = count.max(1);
3277 self
3278 }
3279
3280 pub fn max_concurrent_activity_tasks(mut self, count: usize) -> Self {
3281 self.max_concurrent_activity_tasks = count.max(1);
3282 self
3283 }
3284
3285 pub fn register_workflow<F, Fut>(&mut self, workflow_type: impl Into<String>, handler: F)
3292 where
3293 F: Fn(WorkflowContext, Value) -> Fut + Send + Sync + 'static,
3294 Fut: Future<Output = Result<Value>> + Send + 'static,
3295 {
3296 self.workflows.insert(
3297 workflow_type.into(),
3298 RegisteredWorkflow {
3299 execute: Arc::new(move |ctx, input| Box::pin(handler(ctx, input))),
3300 replay: None,
3301 state_type: None,
3302 },
3303 );
3304 }
3305
3306 pub fn register_replayed_workflow<S, Factory, F, Fut>(
3314 &mut self,
3315 workflow_type: impl Into<String>,
3316 state_factory: Factory,
3317 handler: F,
3318 ) where
3319 S: Clone + Send + Sync + 'static,
3320 Factory: Fn() -> S + Send + Sync + 'static,
3321 F: Fn(WorkflowContext, Value, WorkflowInstance<S>) -> Fut + Send + Sync + 'static,
3322 Fut: Future<Output = Result<Value>> + Send + 'static,
3323 {
3324 let state_factory = Arc::new(state_factory);
3325 let handler = Arc::new(handler);
3326
3327 let execute_factory = Arc::clone(&state_factory);
3328 let execute_handler = Arc::clone(&handler);
3329 let execute = Arc::new(move |ctx: WorkflowContext, input: Value| {
3330 let state = WorkflowInstance::new(execute_factory());
3331 let future = execute_handler(ctx, input, state);
3332 Box::pin(future) as WorkflowFuture
3333 });
3334
3335 let replay = Arc::new(move |ctx: WorkflowContext, input: Value| {
3336 let state = WorkflowInstance::new(state_factory());
3337 let snapshot_state = state.clone();
3338 let snapshot: WorkflowStateSnapshot =
3339 Arc::new(move || Ok(Arc::new(snapshot_state.snapshot()?) as ErasedWorkflowState));
3340 let future = handler(ctx, input, state);
3341 ReplayedWorkflowInvocation {
3342 future: Box::pin(future),
3343 snapshot,
3344 }
3345 });
3346
3347 self.workflows.insert(
3348 workflow_type.into(),
3349 RegisteredWorkflow {
3350 execute,
3351 replay: Some(replay),
3352 state_type: Some(TypeId::of::<S>()),
3353 },
3354 );
3355 }
3356
3357 pub fn register_activity<F, Fut>(&mut self, activity_type: impl Into<String>, handler: F)
3358 where
3359 F: Fn(ActivityContext, Value) -> Fut + Send + Sync + 'static,
3360 Fut: Future<Output = Result<Value>> + Send + 'static,
3361 {
3362 self.activities.insert(
3363 activity_type.into(),
3364 Arc::new(move |ctx, args| Box::pin(handler(ctx, args))),
3365 );
3366 }
3367
3368 pub fn register_query<F, Fut>(
3374 &mut self,
3375 workflow_type: impl Into<String>,
3376 query_name: impl Into<String>,
3377 handler: F,
3378 ) where
3379 F: Fn(QueryContext, Value) -> Fut + Send + Sync + 'static,
3380 Fut: Future<Output = Result<Value>> + Send + 'static,
3381 {
3382 self.queries
3383 .entry(workflow_type.into())
3384 .or_default()
3385 .insert(
3386 query_name.into(),
3387 RegisteredQuery::Snapshot(Arc::new(move |ctx, args| Box::pin(handler(ctx, args)))),
3388 );
3389 }
3390
3391 pub fn register_replayed_query<S, F, Fut>(
3398 &mut self,
3399 workflow_type: impl Into<String>,
3400 query_name: impl Into<String>,
3401 handler: F,
3402 ) where
3403 S: Clone + Send + Sync + 'static,
3404 F: Fn(QueryContext, Arc<S>, Value) -> Fut + Send + Sync + 'static,
3405 Fut: Future<Output = Result<Value>> + Send + 'static,
3406 {
3407 let handler = Arc::new(handler);
3408 let erased_handler: ReplayedQueryHandler = Arc::new(move |ctx, state, args| {
3409 let state = state.downcast::<S>().map_err(|_| {
3410 "registered query state type does not match the replayed workflow state".to_string()
3411 })?;
3412 Ok(Box::pin(handler(ctx, state, args)))
3413 });
3414
3415 self.queries
3416 .entry(workflow_type.into())
3417 .or_default()
3418 .insert(
3419 query_name.into(),
3420 RegisteredQuery::Replayed {
3421 state_type: TypeId::of::<S>(),
3422 handler: erased_handler,
3423 },
3424 );
3425 }
3426
3427 pub async fn register(&self) -> Result<RegisterWorkerResponse> {
3428 self.client
3429 .register_worker_with_capabilities(
3430 &self.worker_id,
3431 &self.task_queue,
3432 self.workflows.keys().cloned().collect(),
3433 self.activities.keys().cloned().collect(),
3434 self.max_concurrent_workflow_tasks,
3435 self.max_concurrent_activity_tasks,
3436 (!self.queries.is_empty())
3437 .then(|| QUERY_TASKS_CAPABILITY.to_string())
3438 .into_iter()
3439 .collect(),
3440 )
3441 .await
3442 }
3443
3444 pub async fn run(&self) -> Result<()> {
3450 self.run_until(std::future::pending::<()>()).await
3451 }
3452
3453 pub async fn run_until<F>(&self, shutdown: F) -> Result<()>
3457 where
3458 F: Future<Output = ()>,
3459 {
3460 let registration = self.register().await?;
3461 let heartbeat_interval = Duration::from_secs(
3462 registration
3463 .heartbeat_interval_seconds
3464 .unwrap_or(self.heartbeat_interval.as_secs().max(1)),
3465 );
3466 let heartbeat = tokio::time::sleep(Duration::ZERO);
3472 tokio::pin!(heartbeat);
3473 tokio::pin!(shutdown);
3474 let stop = Arc::new(AtomicBool::new(false));
3475 let mut workflow_poller = (!self.workflows.is_empty()).then(|| {
3479 let worker = self.clone();
3480 let stop = Arc::clone(&stop);
3481 tokio::spawn(async move { worker.poll_workflows_until_stopped(stop).await })
3482 });
3483 let mut activity_poller = (!self.activities.is_empty()).then(|| {
3484 let worker = self.clone();
3485 let stop = Arc::clone(&stop);
3486 tokio::spawn(async move { worker.poll_activities_until_stopped(stop).await })
3487 });
3488 let mut query_poller = (!self.queries.is_empty()).then(|| {
3489 let worker = self.clone();
3490 let stop = Arc::clone(&stop);
3491 tokio::spawn(async move { worker.poll_queries_until_stopped(stop).await })
3492 });
3493
3494 loop {
3495 tokio::select! {
3496 _ = &mut shutdown => {
3497 stop.store(true, Ordering::SeqCst);
3498 break;
3499 }
3500 _ = &mut heartbeat => {
3501 let result = self.retry_worker_operation(|| {
3502 self.client.heartbeat_worker(
3503 &self.worker_id,
3504 self.max_concurrent_workflow_tasks,
3505 self.max_concurrent_activity_tasks,
3506 )
3507 }).await;
3508 heartbeat
3509 .as_mut()
3510 .reset(tokio::time::Instant::now() + heartbeat_interval);
3511 match result {
3512 Ok(acknowledgement) => {
3513 if let Some(observer) = &self.heartbeat_observer {
3514 observer(&WorkerHeartbeatObservation {
3515 worker_id: self.worker_id.clone(),
3516 task_queue: self.task_queue.clone(),
3517 acknowledged_at_unix_millis: SystemTime::now()
3518 .duration_since(UNIX_EPOCH)
3519 .unwrap_or_default()
3520 .as_millis()
3521 .min(u64::MAX as u128)
3522 as u64,
3523 acknowledgement,
3524 });
3525 }
3526 }
3527 Err(error) => {
3528 stop.store(true, Ordering::SeqCst);
3529 join_pollers(workflow_poller.take(), activity_poller.take(), query_poller.take()).await?;
3530 return Err(error);
3531 }
3532 }
3533 }
3534 result = OptionFuture::from(workflow_poller.as_mut()), if workflow_poller.is_some() => {
3535 workflow_poller = None;
3536 let stopped_by_server = stop.load(Ordering::SeqCst);
3537 stop.store(true, Ordering::SeqCst);
3538 let poller_result = optional_poller_result("workflow", result);
3539 let join_result =
3540 join_pollers(workflow_poller.take(), activity_poller.take(), query_poller.take()).await;
3541 poller_result?;
3542 join_result?;
3543 if stopped_by_server {
3544 return Ok(());
3545 }
3546 return Err(Error::WorkerLoop(
3547 "workflow poller stopped unexpectedly".to_string(),
3548 ));
3549 }
3550 result = OptionFuture::from(activity_poller.as_mut()), if activity_poller.is_some() => {
3551 activity_poller = None;
3552 let stopped_by_server = stop.load(Ordering::SeqCst);
3553 stop.store(true, Ordering::SeqCst);
3554 let poller_result = optional_poller_result("activity", result);
3555 let join_result =
3556 join_pollers(workflow_poller.take(), activity_poller.take(), query_poller.take()).await;
3557 poller_result?;
3558 join_result?;
3559 if stopped_by_server {
3560 return Ok(());
3561 }
3562 return Err(Error::WorkerLoop(
3563 "activity poller stopped unexpectedly".to_string(),
3564 ));
3565 }
3566 result = OptionFuture::from(query_poller.as_mut()), if query_poller.is_some() => {
3567 query_poller = None;
3568 let stopped_by_server = stop.load(Ordering::SeqCst);
3569 stop.store(true, Ordering::SeqCst);
3570 let poller_result = optional_poller_result("query", result);
3571 let join_result =
3572 join_pollers(workflow_poller.take(), activity_poller.take(), query_poller.take()).await;
3573 poller_result?;
3574 join_result?;
3575 if stopped_by_server {
3576 return Ok(());
3577 }
3578 return Err(Error::WorkerLoop(
3579 "query poller stopped unexpectedly".to_string(),
3580 ));
3581 }
3582 }
3583 }
3584
3585 join_pollers(
3586 workflow_poller.take(),
3587 activity_poller.take(),
3588 query_poller.take(),
3589 )
3590 .await
3591 }
3592
3593 pub async fn run_once(&self) -> Result<usize> {
3607 let mut handled = 0;
3608 match self.poll_workflow_once().await? {
3609 ManagedPollOutcome::Handled => handled += 1,
3610 ManagedPollOutcome::Stop => return Ok(handled),
3611 ManagedPollOutcome::Idle => {}
3612 }
3613 match self.poll_activity_once().await? {
3614 ManagedPollOutcome::Handled => handled += 1,
3615 ManagedPollOutcome::Stop => return Ok(handled),
3616 ManagedPollOutcome::Idle => {}
3617 }
3618 if !self.queries.is_empty() {
3619 match self.poll_query_once().await? {
3620 ManagedPollOutcome::Handled => handled += 1,
3621 ManagedPollOutcome::Stop => return Ok(handled),
3622 ManagedPollOutcome::Idle => {}
3623 }
3624 }
3625 Ok(handled)
3626 }
3627
3628 async fn poll_workflow_once(&self) -> Result<ManagedPollOutcome> {
3629 let response = self
3630 .retry_worker_operation(|| {
3631 self.client.poll_workflow_task_response(
3632 &self.worker_id,
3633 &self.task_queue,
3634 self.poll_timeout,
3635 )
3636 })
3637 .await?;
3638 if response.outcome().should_stop() {
3639 return Ok(ManagedPollOutcome::Stop);
3640 }
3641 let Some(task) = response.task else {
3642 return Ok(ManagedPollOutcome::Idle);
3643 };
3644
3645 let task_id = task.task_id.clone();
3646 let attempt = task.workflow_task_attempt;
3647 let run_id = task.run_id.clone();
3648 let lease_owner = task
3649 .lease_owner
3650 .clone()
3651 .unwrap_or_else(|| self.worker_id.clone());
3652
3653 match self.execute_workflow_task(task) {
3654 Ok(commands) if commands.is_empty() => {
3655 self.client
3661 .fail_workflow_task_with_type(
3662 &task_id,
3663 &lease_owner,
3664 attempt,
3665 WORKFLOW_TASK_WAITING_FOR_HISTORY_MESSAGE,
3666 WORKFLOW_TASK_WAITING_FOR_HISTORY_TYPE,
3667 )
3668 .await?;
3669 }
3670 Ok(commands) => {
3671 let completion = self
3672 .client
3673 .complete_workflow_task(&task_id, &lease_owner, attempt, commands)
3674 .await;
3675 if let Err(error) = completion {
3676 if !workflow_task_completion_is_terminal_timeout(
3677 &error,
3678 &task_id,
3679 attempt,
3680 run_id.as_deref(),
3681 ) {
3682 return Err(error);
3683 }
3684 }
3685 }
3686 Err(error) => {
3687 self.client
3688 .fail_workflow_task(&task_id, &lease_owner, attempt, error.to_string())
3689 .await?;
3690 }
3691 }
3692
3693 Ok(ManagedPollOutcome::Handled)
3694 }
3695
3696 async fn poll_workflows_until_stopped(self, stop: Arc<AtomicBool>) -> Result<()> {
3697 while !stop.load(Ordering::SeqCst) {
3698 if self.poll_workflow_once().await? == ManagedPollOutcome::Stop {
3699 stop.store(true, Ordering::SeqCst);
3700 break;
3701 }
3702 }
3703
3704 Ok(())
3705 }
3706
3707 async fn poll_activity_once(&self) -> Result<ManagedPollOutcome> {
3708 let response = self
3709 .retry_worker_operation(|| {
3710 self.client.poll_activity_task_response(
3711 &self.worker_id,
3712 &self.task_queue,
3713 self.poll_timeout,
3714 )
3715 })
3716 .await?;
3717 if response.outcome().should_stop() {
3718 return Ok(ManagedPollOutcome::Stop);
3719 }
3720 let Some(task) = response.task else {
3721 return Ok(ManagedPollOutcome::Idle);
3722 };
3723
3724 let task_id = task.task_id.clone();
3725 let attempt_id = task
3726 .activity_attempt_id
3727 .clone()
3728 .or(task.attempt_id.clone())
3729 .unwrap_or_default();
3730 let lease_owner = task
3731 .lease_owner
3732 .clone()
3733 .unwrap_or_else(|| self.worker_id.clone());
3734 let codec = task.payload_codec.clone();
3735 let result = self.execute_activity_task(task).await;
3736 match result {
3737 Ok(value) => {
3738 let completion = self
3739 .client
3740 .complete_activity_task(&task_id, &attempt_id, &lease_owner, value, &codec)
3741 .await;
3742 if let Err(error) = completion {
3743 if !activity_task_rejection_is_final(&error) {
3744 return Err(error);
3745 }
3746 }
3747 }
3748 Err(error) => {
3749 let failure = self
3750 .client
3751 .fail_activity_task(
3752 &task_id,
3753 &attempt_id,
3754 &lease_owner,
3755 error.to_string(),
3756 false,
3757 )
3758 .await;
3759 if let Err(error) = failure {
3760 if !activity_task_rejection_is_final(&error) {
3761 return Err(error);
3762 }
3763 }
3764 }
3765 }
3766
3767 Ok(ManagedPollOutcome::Handled)
3768 }
3769
3770 async fn poll_activities_until_stopped(self, stop: Arc<AtomicBool>) -> Result<()> {
3771 while !stop.load(Ordering::SeqCst) {
3772 if self.poll_activity_once().await? == ManagedPollOutcome::Stop {
3773 stop.store(true, Ordering::SeqCst);
3774 break;
3775 }
3776 }
3777
3778 Ok(())
3779 }
3780
3781 async fn poll_query_once(&self) -> Result<ManagedPollOutcome> {
3782 let response = self
3783 .retry_worker_operation(|| {
3784 self.client.poll_query_task_response(
3785 &self.worker_id,
3786 &self.task_queue,
3787 self.poll_timeout,
3788 )
3789 })
3790 .await?;
3791 if response.outcome().should_stop() {
3792 return Ok(ManagedPollOutcome::Stop);
3793 }
3794 let Some(task) = response.task else {
3795 return Ok(ManagedPollOutcome::Idle);
3796 };
3797
3798 let query_task_id = task.query_task_id.clone();
3799 let attempt = task.query_task_attempt;
3800 let lease_owner = task
3801 .lease_owner
3802 .clone()
3803 .unwrap_or_else(|| self.worker_id.clone());
3804 let codec = task.payload_codec.clone();
3805
3806 match self.execute_query_task(task).await {
3807 Ok(value) => {
3808 let result_envelope = match encode_value_envelope(&value, &codec) {
3809 Ok(result_envelope) => result_envelope,
3810 Err(error) => {
3811 let failure = self
3812 .client
3813 .fail_query_task(
3814 &query_task_id,
3815 &lease_owner,
3816 attempt,
3817 error.to_string(),
3818 "query_result_encode_failed",
3819 "QueryResultEncodeFailed",
3820 )
3821 .await;
3822 if let Err(error) = failure {
3823 if !query_task_rejection_is_final(&error) {
3824 return Err(error);
3825 }
3826 }
3827 return Ok(ManagedPollOutcome::Handled);
3828 }
3829 };
3830
3831 if let Err(error) = self
3832 .client
3833 .complete_query_task_with_envelope(
3834 &query_task_id,
3835 &lease_owner,
3836 attempt,
3837 value,
3838 result_envelope,
3839 )
3840 .await
3841 {
3842 if !query_task_rejection_is_final(&error) {
3843 return Err(error);
3844 }
3845 }
3846 }
3847 Err(failure) => {
3848 let result = self
3849 .client
3850 .fail_query_task(
3851 &query_task_id,
3852 &lease_owner,
3853 attempt,
3854 failure.message,
3855 failure.reason,
3856 failure.failure_type,
3857 )
3858 .await;
3859 if let Err(error) = result {
3860 if !query_task_rejection_is_final(&error) {
3861 return Err(error);
3862 }
3863 }
3864 }
3865 }
3866
3867 Ok(ManagedPollOutcome::Handled)
3868 }
3869
3870 async fn poll_queries_until_stopped(self, stop: Arc<AtomicBool>) -> Result<()> {
3871 while !stop.load(Ordering::SeqCst) {
3872 if self.poll_query_once().await? == ManagedPollOutcome::Stop {
3873 stop.store(true, Ordering::SeqCst);
3874 break;
3875 }
3876 }
3877
3878 Ok(())
3879 }
3880
3881 async fn retry_worker_operation<T, F, Fut>(&self, mut operation: F) -> Result<T>
3882 where
3883 F: FnMut() -> Fut,
3884 Fut: Future<Output = Result<T>>,
3885 {
3886 let mut retries = 0;
3887
3888 loop {
3889 match operation().await {
3890 Err(error)
3891 if worker_operation_is_retryable(&error)
3892 && retries < self.retry_policy.max_retries =>
3893 {
3894 retries += 1;
3895 tokio::time::sleep(worker_retry_delay(self.retry_policy, retries)).await;
3896 }
3897 result => return result,
3898 }
3899 }
3900 }
3901
3902 async fn execute_query_task(
3903 &self,
3904 mut task: QueryTask,
3905 ) -> std::result::Result<Value, QueryTaskExecutionFailure> {
3906 if !matches!(task.payload_codec.as_str(), DEFAULT_CODEC | JSON_CODEC) {
3907 return Err(QueryTaskExecutionFailure::new(
3908 "query_payload_decode_failed",
3909 format!(
3910 "cannot decode query payload with unsupported codec {:?}",
3911 task.payload_codec
3912 ),
3913 "QueryPayloadDecodeFailed",
3914 ));
3915 }
3916
3917 if !self.workflows.contains_key(&task.workflow_type) {
3918 return Err(QueryTaskExecutionFailure::new(
3919 "query_workflow_type_not_registered",
3920 format!("no workflow registered for type {:?}", task.workflow_type),
3921 "WorkflowTypeNotRegistered",
3922 ));
3923 }
3924
3925 let Some(handlers) = self.queries.get(&task.workflow_type) else {
3926 return Err(QueryTaskExecutionFailure::new(
3927 "query_handler_unavailable",
3928 format!(
3929 "query handlers are unavailable for workflow type {:?}",
3930 task.workflow_type
3931 ),
3932 "QueryHandlerUnavailable",
3933 ));
3934 };
3935 let Some(query) = handlers.get(&task.query_name) else {
3936 return Err(QueryTaskExecutionFailure::new(
3937 "rejected_unknown_query",
3938 format!("unknown query {:?}", task.query_name),
3939 "QueryFailed",
3940 ));
3941 };
3942
3943 let args = decode_task_arguments(task.query_arguments.as_ref(), &task.payload_codec)
3944 .map_err(|error| {
3945 QueryTaskExecutionFailure::new(
3946 "query_payload_decode_failed",
3947 format!("cannot decode query arguments: {error}"),
3948 "QueryPayloadDecodeFailed",
3949 )
3950 })?;
3951 let workflow_input =
3952 decode_task_arguments(task.workflow_arguments.as_ref(), &task.payload_codec).map_err(
3953 |error| {
3954 QueryTaskExecutionFailure::new(
3955 "query_workflow_state_unavailable",
3956 format!("cannot decode workflow start input: {error}"),
3957 "QueryWorkflowStateUnavailable",
3958 )
3959 },
3960 )?;
3961 hydrate_query_history_from_export(&mut task).map_err(|error| {
3962 QueryTaskExecutionFailure::new(
3963 "query_workflow_state_unavailable",
3964 format!("cannot restore query history snapshot: {error}"),
3965 "QueryWorkflowStateUnavailable",
3966 )
3967 })?;
3968 enrich_query_history_from_export(&mut task).map_err(|error| {
3969 QueryTaskExecutionFailure::new(
3970 "query_workflow_state_unavailable",
3971 format!("cannot restore compact query history payloads: {error}"),
3972 "QueryWorkflowStateUnavailable",
3973 )
3974 })?;
3975 let signal_events = query_signal_events(&task).map_err(|error| {
3976 QueryTaskExecutionFailure::new(
3977 "query_workflow_state_unavailable",
3978 format!("cannot decode committed workflow signals: {error}"),
3979 "QueryWorkflowStateUnavailable",
3980 )
3981 })?;
3982 let history_events = Arc::new(std::mem::take(&mut task.history_events));
3983 let context = QueryContext {
3984 workflow_id: task.workflow_id,
3985 run_id: task.run_id,
3986 workflow_type: task.workflow_type.clone(),
3987 run_status: task.run_status,
3988 workflow_input,
3989 history_events: Arc::clone(&history_events),
3990 signal_events: Arc::new(signal_events),
3991 };
3992
3993 let future = match query {
3994 RegisteredQuery::Snapshot(handler) => handler(context, args),
3995 RegisteredQuery::Replayed {
3996 state_type,
3997 handler,
3998 } => {
3999 let workflow = self
4000 .workflows
4001 .get(&task.workflow_type)
4002 .expect("workflow registration was checked above");
4003 if workflow.state_type != Some(*state_type) {
4004 return Err(QueryTaskExecutionFailure::new(
4005 "query_workflow_state_unavailable",
4006 "replayed query state type does not match its workflow registration",
4007 "QueryWorkflowStateUnavailable",
4008 ));
4009 }
4010 let replay = workflow.replay.as_ref().ok_or_else(|| {
4011 QueryTaskExecutionFailure::new(
4012 "query_workflow_state_unavailable",
4013 format!(
4014 "workflow type {:?} is not registered for instance-state replay",
4015 task.workflow_type
4016 ),
4017 "QueryWorkflowStateUnavailable",
4018 )
4019 })?;
4020 let workflow_state = Arc::new(Mutex::new(
4021 WorkflowState::new_with_identity(
4022 history_events.as_ref().clone(),
4023 context.workflow_id.clone(),
4024 context.run_id.clone(),
4025 self.task_queue.clone(),
4026 task.payload_codec,
4027 None,
4028 )
4029 .map_err(|error| {
4030 QueryTaskExecutionFailure::new(
4031 "query_workflow_state_unavailable",
4032 format!("workflow replay failed before query: {error}"),
4033 "QueryWorkflowStateUnavailable",
4034 )
4035 })?,
4036 ));
4037 let workflow_context = WorkflowContext {
4038 state: workflow_state,
4039 };
4040 let mut invocation =
4041 replay(workflow_context.clone(), context.workflow_input.clone());
4042 let mut cx = TaskContext::from_waker(noop_waker_ref());
4043 match invocation.future.as_mut().poll(&mut cx) {
4044 Poll::Ready(Ok(_)) => {
4045 workflow_context
4046 .ensure_history_consumed()
4047 .map_err(|error| {
4048 QueryTaskExecutionFailure::new(
4049 "query_workflow_state_unavailable",
4050 format!("workflow replay failed before query: {error}"),
4051 "QueryWorkflowStateUnavailable",
4052 )
4053 })?;
4054 }
4055 Poll::Ready(Err(error)) => {
4056 return Err(QueryTaskExecutionFailure::new(
4057 "query_workflow_state_unavailable",
4058 format!("workflow replay failed before query: {error}"),
4059 "QueryWorkflowStateUnavailable",
4060 ));
4061 }
4062 Poll::Pending => {
4063 let commands = workflow_context.take_commands().map_err(|error| {
4064 QueryTaskExecutionFailure::new(
4065 "query_workflow_state_unavailable",
4066 format!("workflow replay failed before query: {error}"),
4067 "QueryWorkflowStateUnavailable",
4068 )
4069 })?;
4070 if commands.is_empty()
4071 && !workflow_context
4072 .matched_recorded_pending()
4073 .map_err(|error| {
4074 QueryTaskExecutionFailure::new(
4075 "query_workflow_state_unavailable",
4076 format!("workflow replay failed before query: {error}"),
4077 "QueryWorkflowStateUnavailable",
4078 )
4079 })?
4080 {
4081 return Err(QueryTaskExecutionFailure::new(
4082 "query_workflow_state_unavailable",
4083 "workflow replay yielded without a durable command",
4084 "QueryWorkflowStateUnavailable",
4085 ));
4086 }
4087 }
4088 }
4089 let state = (invocation.snapshot)().map_err(|error| {
4090 QueryTaskExecutionFailure::new(
4091 "query_workflow_state_unavailable",
4092 format!("cannot snapshot replayed workflow state: {error}"),
4093 "QueryWorkflowStateUnavailable",
4094 )
4095 })?;
4096 handler(context, state, args).map_err(|message| {
4097 QueryTaskExecutionFailure::new(
4098 "query_workflow_state_unavailable",
4099 message,
4100 "QueryWorkflowStateUnavailable",
4101 )
4102 })?
4103 }
4104 };
4105
4106 future.await.map_err(|error| {
4107 QueryTaskExecutionFailure::new("query_rejected", error.to_string(), "QueryFailed")
4108 })
4109 }
4110
4111 fn execute_workflow_task(&self, task: WorkflowTask) -> Result<Vec<Value>> {
4112 let workflow = self
4113 .workflows
4114 .get(&task.workflow_type)
4115 .ok_or_else(|| Error::WorkflowNotRegistered(task.workflow_type.clone()))?;
4116 let input = decode_task_arguments(task.arguments.as_ref(), &task.payload_codec)?;
4117 let resume_signal = decode_resume_signal(&task)?;
4118 let history_budget = WorkflowHistoryBudget {
4119 event_count: task
4120 .total_history_events
4121 .unwrap_or_else(|| u64::try_from(task.history_events.len()).unwrap_or(u64::MAX)),
4122 size_bytes: task.history_size_bytes,
4123 continue_as_new_recommended: task.continue_as_new_recommended.unwrap_or(false),
4124 pressure: task.history_budget_pressure.clone(),
4125 };
4126 let mut workflow_state = WorkflowState::new_with_identity(
4127 task.history_events,
4128 task.workflow_id,
4129 task.run_id,
4130 self.task_queue.clone(),
4131 task.payload_codec.clone(),
4132 resume_signal,
4133 )?;
4134 workflow_state.history_budget = history_budget;
4135 let state = Arc::new(Mutex::new(workflow_state));
4136 let ctx = WorkflowContext { state };
4137 let mut future = (workflow.execute)(ctx.clone(), input);
4138 let mut cx = TaskContext::from_waker(noop_waker_ref());
4139
4140 match future.as_mut().poll(&mut cx) {
4141 Poll::Ready(Ok(result)) => {
4142 ctx.ensure_history_consumed()?;
4143 let result = encode_value_envelope(&result, &task.payload_codec)?;
4144 let mut commands = ctx.take_commands()?;
4145 commands.push(json!({
4146 "type": "complete_workflow",
4147 "result": result
4148 }));
4149 Ok(commands)
4150 }
4151 Poll::Ready(Err(error)) => {
4152 if let Error::ContinueAsNew(request) = error {
4153 let mut commands = ctx.take_commands()?;
4154 if let Some(command) = ctx.continue_as_new_command(request)? {
4155 commands.push(command);
4156 }
4157 ctx.ensure_history_consumed()?;
4158 return Ok(commands);
4159 }
4160 ctx.ensure_history_consumed()?;
4163 if workflow_task_integrity_error(&error) {
4164 return Err(error);
4169 }
4170 let mut commands = ctx.take_commands()?;
4171 commands.push(workflow_failure_command(&error));
4172 Ok(commands)
4173 }
4174 Poll::Pending => {
4175 let commands = ctx.take_commands()?;
4176 if commands.is_empty() && !ctx.matched_recorded_pending()? {
4177 Err(Error::WorkflowYieldedWithoutCommand)
4178 } else {
4179 Ok(commands)
4180 }
4181 }
4182 }
4183 }
4184
4185 async fn execute_activity_task(&self, task: ActivityTask) -> Result<Value> {
4186 let handler = self
4187 .activities
4188 .get(&task.activity_type)
4189 .ok_or_else(|| Error::ActivityNotRegistered(task.activity_type.clone()))?;
4190 let args = decode_task_arguments(task.arguments.as_ref(), &task.payload_codec)?;
4191 let attempt_id = task
4192 .activity_attempt_id
4193 .clone()
4194 .or(task.attempt_id.clone())
4195 .unwrap_or_default();
4196 let lease_owner = task
4197 .lease_owner
4198 .clone()
4199 .unwrap_or_else(|| self.worker_id.clone());
4200 let ctx = ActivityContext {
4201 client: self.client.clone(),
4202 task_id: task.task_id,
4203 activity_attempt_id: attempt_id,
4204 lease_owner,
4205 activity_type: task.activity_type,
4206 attempt_number: task.attempt_number,
4207 task_queue: self.task_queue.clone(),
4208 worker_id: self.worker_id.clone(),
4209 };
4210
4211 handler(ctx, args).await
4212 }
4213}
4214
4215fn poller_result(
4216 kind: &str,
4217 result: std::result::Result<Result<()>, tokio::task::JoinError>,
4218) -> Result<()> {
4219 match result {
4220 Ok(result) => result,
4221 Err(error) => Err(Error::WorkerLoop(format!(
4222 "{kind} poller join error: {error}"
4223 ))),
4224 }
4225}
4226
4227fn optional_poller_result(
4228 kind: &str,
4229 result: Option<std::result::Result<Result<()>, tokio::task::JoinError>>,
4230) -> Result<()> {
4231 match result {
4232 Some(result) => poller_result(kind, result),
4233 None => Ok(()),
4234 }
4235}
4236
4237async fn join_pollers(
4238 workflow_poller: Option<tokio::task::JoinHandle<Result<()>>>,
4239 activity_poller: Option<tokio::task::JoinHandle<Result<()>>>,
4240 query_poller: Option<tokio::task::JoinHandle<Result<()>>>,
4241) -> Result<()> {
4242 let mut first_error = None;
4243
4244 if let Some(handle) = workflow_poller {
4245 if let Err(error) = poller_result("workflow", handle.await) {
4246 first_error.get_or_insert(error);
4247 }
4248 }
4249
4250 if let Some(handle) = activity_poller {
4251 if let Err(error) = poller_result("activity", handle.await) {
4252 first_error.get_or_insert(error);
4253 }
4254 }
4255
4256 if let Some(handle) = query_poller {
4257 if let Err(error) = poller_result("query", handle.await) {
4258 first_error.get_or_insert(error);
4259 }
4260 }
4261
4262 if let Some(error) = first_error {
4263 Err(error)
4264 } else {
4265 Ok(())
4266 }
4267}
4268
4269fn default_worker_id() -> String {
4270 let millis = SystemTime::now()
4271 .duration_since(UNIX_EPOCH)
4272 .unwrap_or_default()
4273 .as_millis();
4274 format!("rust-worker-{}-{millis}", std::process::id())
4275}
4276
4277fn unique_request_id(prefix: &str) -> String {
4278 let nanos = SystemTime::now()
4279 .duration_since(UNIX_EPOCH)
4280 .unwrap_or_default()
4281 .as_nanos();
4282 format!("{prefix}-{}-{nanos}", std::process::id())
4283}
4284
4285#[derive(Debug)]
4286struct QueryTaskExecutionFailure {
4287 reason: String,
4288 message: String,
4289 failure_type: String,
4290}
4291
4292impl QueryTaskExecutionFailure {
4293 fn new(
4294 reason: impl Into<String>,
4295 message: impl Into<String>,
4296 failure_type: impl Into<String>,
4297 ) -> Self {
4298 Self {
4299 reason: reason.into(),
4300 message: message.into(),
4301 failure_type: failure_type.into(),
4302 }
4303 }
4304}
4305
4306#[derive(Clone, Debug)]
4312pub struct WorkflowInstance<S> {
4313 state: Arc<Mutex<S>>,
4314}
4315
4316impl<S> WorkflowInstance<S> {
4317 fn new(state: S) -> Self {
4318 Self {
4319 state: Arc::new(Mutex::new(state)),
4320 }
4321 }
4322
4323 pub fn read<R>(&self, reader: impl FnOnce(&S) -> R) -> Result<R> {
4325 let state = self
4326 .state
4327 .lock()
4328 .map_err(|_| Error::WorkflowStatePoisoned)?;
4329 Ok(reader(&state))
4330 }
4331
4332 pub fn update<R>(&self, transition: impl FnOnce(&mut S) -> R) -> Result<R> {
4334 let mut state = self
4335 .state
4336 .lock()
4337 .map_err(|_| Error::WorkflowStatePoisoned)?;
4338 Ok(transition(&mut state))
4339 }
4340}
4341
4342impl<S: Clone> WorkflowInstance<S> {
4343 fn snapshot(&self) -> Result<S> {
4344 self.read(Clone::clone)
4345 }
4346}
4347
4348#[derive(Clone, Debug)]
4349pub struct WorkflowContext {
4350 state: Arc<Mutex<WorkflowState>>,
4351}
4352
4353impl WorkflowContext {
4354 pub fn workflow_identity(&self) -> Result<WorkflowIdentity> {
4356 let state = self
4357 .state
4358 .lock()
4359 .map_err(|_| Error::WorkflowStatePoisoned)?;
4360 Ok(WorkflowIdentity {
4361 workflow_id: state.workflow_id.clone(),
4362 run_id: state.run_id.clone(),
4363 })
4364 }
4365
4366 pub fn history_budget(&self) -> Result<WorkflowHistoryBudget> {
4368 let state = self
4369 .state
4370 .lock()
4371 .map_err(|_| Error::WorkflowStatePoisoned)?;
4372 Ok(state.history_budget.clone())
4373 }
4374
4375 pub fn continue_as_new<T: Serialize>(&self, args: T) -> Result<Value> {
4381 self.continue_as_new_with_options(ContinueAsNewOptions::new(), args)
4382 }
4383
4384 pub fn continue_as_new_with_options<T: Serialize>(
4386 &self,
4387 options: ContinueAsNewOptions,
4388 args: T,
4389 ) -> Result<Value> {
4390 options.validate()?;
4391 Err(Error::ContinueAsNew(ContinueAsNewRequest {
4392 arguments: normalize_arguments(serde_json::to_value(args)?),
4393 options,
4394 }))
4395 }
4396
4397 pub fn activity<T: Serialize>(
4398 &self,
4399 activity_type: impl Into<String>,
4400 args: T,
4401 ) -> ActivityCall {
4402 self.activity_with_options(activity_type, ActivityOptions::new(), args)
4403 }
4404
4405 pub fn activity_on_queue<T, Q>(
4406 &self,
4407 activity_type: impl Into<String>,
4408 task_queue: Option<Q>,
4409 args: T,
4410 ) -> ActivityCall
4411 where
4412 T: Serialize,
4413 Q: Into<String>,
4414 {
4415 let mut options = ActivityOptions::new();
4416 options.task_queue = task_queue.map(Into::into);
4417 self.activity_with_options(activity_type, options, args)
4418 }
4419
4420 pub fn activity_with_options<T: Serialize>(
4458 &self,
4459 activity_type: impl Into<String>,
4460 options: ActivityOptions,
4461 args: T,
4462 ) -> ActivityCall {
4463 ActivityCall {
4464 ctx: self.clone(),
4465 activity_type: activity_type.into(),
4466 options,
4467 args: Some(serde_json::to_value(args).map_err(Error::from)),
4468 scheduled: false,
4469 }
4470 }
4471
4472 pub fn wait_signal(&self, signal_name: impl Into<String>) -> SignalCall {
4473 SignalCall {
4474 ctx: self.clone(),
4475 signal_name: signal_name.into(),
4476 opened_wait: false,
4477 matched_pending: false,
4478 }
4479 }
4480
4481 pub fn sleep(&self, duration: Duration) -> TimerCall {
4502 let delay_seconds = duration
4503 .as_secs()
4504 .checked_add(u64::from(duration.subsec_nanos() > 0));
4505 TimerCall {
4506 ctx: self.clone(),
4507 delay_seconds,
4508 scheduled: false,
4509 matched_pending: false,
4510 }
4511 }
4512
4513 pub fn start_timer(&self, duration: Duration) -> TimerCall {
4515 self.sleep(duration)
4516 }
4517
4518 pub fn side_effect<T, F>(&self, callback: F) -> Result<T>
4525 where
4526 T: Serialize + DeserializeOwned,
4527 F: FnOnce() -> T,
4528 {
4529 {
4530 let mut state = self
4531 .state
4532 .lock()
4533 .map_err(|_| Error::WorkflowStatePoisoned)?;
4534 if let Some(recorded) = state.recorded_commands.get(state.command_cursor).cloned() {
4535 return match recorded {
4536 RecordedCommand::SideEffect { sequence, value } => {
4537 state.command_cursor += 1;
4538 serde_json::from_value(value).map_err(|error| {
4539 Error::NonDeterministicReplay(ReplayFailure::new(
4540 "side_effect_type_mismatch",
4541 Some(sequence),
4542 Some(std::any::type_name::<T>().to_string()),
4543 Some(error.to_string()),
4544 "recorded side-effect value is incompatible with the requested Rust type",
4545 ))
4546 })
4547 }
4548 other => Err(command_mismatch(&other, "side effect")),
4549 };
4550 }
4551 }
4552
4553 let value = callback();
4554 let json_value = serde_json::to_value(&value)?;
4555 let mut state = self
4556 .state
4557 .lock()
4558 .map_err(|_| Error::WorkflowStatePoisoned)?;
4559 let result = encode_value_envelope(&json_value, &state.payload_codec)?;
4560 state.commands.push(json!({
4561 "type": "record_side_effect",
4562 "result": result,
4563 }));
4564 Ok(value)
4565 }
4566
4567 pub fn uuid_v4(&self) -> Result<Uuid> {
4569 self.side_effect(Uuid::new_v4)
4570 }
4571
4572 pub fn get_version(
4575 &self,
4576 change_id: impl Into<String>,
4577 min_supported: i32,
4578 max_supported: i32,
4579 ) -> Result<i32> {
4580 let change_id = change_id.into();
4581 if change_id.trim().is_empty() {
4582 return Err(Error::NonDeterministicReplay(ReplayFailure::new(
4583 "version_change_id_invalid",
4584 None,
4585 Some("non-empty change ID".to_string()),
4586 Some(change_id),
4587 "version markers require a stable non-empty change ID",
4588 )));
4589 }
4590 if min_supported > max_supported {
4591 return Err(Error::NonDeterministicReplay(ReplayFailure::new(
4592 "version_range_invalid",
4593 None,
4594 Some("min_supported <= max_supported".to_string()),
4595 Some(format!("{min_supported}..={max_supported}")),
4596 "version marker supported range is invalid",
4597 )));
4598 }
4599
4600 let mut state = self
4601 .state
4602 .lock()
4603 .map_err(|_| Error::WorkflowStatePoisoned)?;
4604 if let Some((version, sequence)) = state.version_markers.get(&change_id).copied() {
4605 ensure_version_supported(&change_id, version, min_supported, max_supported, sequence)?;
4606 return Ok(version);
4607 }
4608
4609 if let Some(recorded) = state.recorded_commands.get(state.command_cursor).cloned() {
4610 return match recorded {
4611 RecordedCommand::VersionMarker {
4612 sequence,
4613 change_id: recorded_change_id,
4614 version,
4615 ..
4616 } => {
4617 if recorded_change_id != change_id {
4618 return Err(Error::NonDeterministicReplay(ReplayFailure::new(
4619 "version_change_id_mismatch",
4620 Some(sequence),
4621 Some(recorded_change_id),
4622 Some(change_id),
4623 "recorded version marker change ID differs from current workflow code",
4624 )));
4625 }
4626 ensure_version_supported(
4627 &change_id,
4628 version,
4629 min_supported,
4630 max_supported,
4631 sequence,
4632 )?;
4633 state.command_cursor += 1;
4634 state.version_markers.insert(change_id, (version, sequence));
4635 Ok(version)
4636 }
4637 other => Err(command_mismatch(
4638 &other,
4639 format!("version marker:{change_id}"),
4640 )),
4641 };
4642 }
4643
4644 let version = max_supported;
4645 state.commands.push(json!({
4646 "type": "record_version_marker",
4647 "change_id": change_id,
4648 "version": version,
4649 "min_supported": min_supported,
4650 "max_supported": max_supported,
4651 }));
4652 state.version_markers.insert(change_id, (version, 0));
4655 Ok(version)
4656 }
4657
4658 pub fn patched(&self, change_id: impl Into<String>) -> Result<bool> {
4660 Ok(self.get_version(change_id, -1, 1)? == 1)
4661 }
4662
4663 pub fn deprecate_patch(&self, change_id: impl Into<String>) -> Result<()> {
4665 self.get_version(change_id, -1, 1).map(|_| ())
4666 }
4667
4668 pub fn start_child_workflow<T: Serialize>(
4695 &self,
4696 workflow_type: impl Into<String>,
4697 options: ChildWorkflowOptions,
4698 args: T,
4699 ) -> ChildWorkflowCall {
4700 ChildWorkflowCall {
4701 ctx: self.clone(),
4702 workflow_type: workflow_type.into(),
4703 options,
4704 args: Some(serde_json::to_value(args).map_err(Error::from)),
4705 scheduled: false,
4706 matched_pending: false,
4707 }
4708 }
4709
4710 fn take_commands(&self) -> Result<Vec<Value>> {
4711 let mut state = self
4712 .state
4713 .lock()
4714 .map_err(|_| Error::WorkflowStatePoisoned)?;
4715 Ok(std::mem::take(&mut state.commands))
4716 }
4717
4718 fn continue_as_new_command(&self, request: ContinueAsNewRequest) -> Result<Option<Value>> {
4719 let mut state = self
4720 .state
4721 .lock()
4722 .map_err(|_| Error::WorkflowStatePoisoned)?;
4723
4724 if let Some(recorded) = state.recorded_commands.get(state.command_cursor).cloned() {
4725 return Err(command_mismatch(&recorded, "continue as new"));
4726 }
4727 if state.recorded_continue_as_new_sequence.is_some() {
4728 state.continue_as_new_consumed = true;
4729 return Ok(None);
4730 }
4731
4732 let arguments = encode_value_envelope(&request.arguments, &state.payload_codec)?;
4733 let mut command = serde_json::Map::from_iter([
4734 ("type".to_string(), json!("continue_as_new")),
4735 ("arguments".to_string(), arguments),
4736 ("queue".to_string(), json!(state.task_queue.clone())),
4737 ]);
4738 if let Some(workflow_type) = request.options.workflow_type {
4739 command.insert("workflow_type".to_string(), json!(workflow_type));
4740 }
4741 if let Some(task_queue) = request.options.task_queue {
4742 command.insert("queue".to_string(), json!(task_queue));
4743 }
4744 Ok(Some(Value::Object(command)))
4745 }
4746
4747 fn matched_recorded_pending(&self) -> Result<bool> {
4748 let state = self
4749 .state
4750 .lock()
4751 .map_err(|_| Error::WorkflowStatePoisoned)?;
4752 Ok(state.matched_recorded_pending)
4753 }
4754
4755 fn ensure_history_consumed(&self) -> Result<()> {
4756 let state = self
4757 .state
4758 .lock()
4759 .map_err(|_| Error::WorkflowStatePoisoned)?;
4760 if let Some(command) = state.recorded_commands.get(state.command_cursor) {
4761 return Err(Error::NonDeterministicReplay(ReplayFailure::new(
4762 "recorded_commands_unconsumed",
4763 Some(command.sequence()),
4764 Some(command.shape().to_string()),
4765 Some("workflow completion".to_string()),
4766 "workflow completed before consuming all recorded durable commands",
4767 )));
4768 }
4769 if let Some(sequence) = state
4770 .recorded_continue_as_new_sequence
4771 .filter(|_| !state.continue_as_new_consumed)
4772 {
4773 return Err(Error::NonDeterministicReplay(ReplayFailure::new(
4774 "recorded_continue_as_new_unconsumed",
4775 Some(sequence),
4776 Some("continue as new".to_string()),
4777 Some("workflow completion".to_string()),
4778 "workflow completed without consuming its recorded continue-as-new transition",
4779 )));
4780 }
4781 Ok(())
4782 }
4783}
4784
4785#[derive(Debug)]
4786struct WorkflowState {
4787 history: Vec<HistoryEvent>,
4788 workflow_id: Option<String>,
4789 run_id: Option<String>,
4790 task_queue: String,
4791 payload_codec: String,
4792 history_budget: WorkflowHistoryBudget,
4793 resume_signal: Option<ResumeSignal>,
4794 recorded_commands: Vec<RecordedCommand>,
4795 recorded_continue_as_new_sequence: Option<u64>,
4796 continue_as_new_consumed: bool,
4797 command_cursor: usize,
4798 matched_recorded_pending: bool,
4799 signal_cursors: HashMap<String, usize>,
4800 version_markers: HashMap<String, (i32, u64)>,
4801 commands: Vec<Value>,
4802}
4803
4804impl WorkflowState {
4805 #[cfg(test)]
4806 fn new(
4807 history: Vec<HistoryEvent>,
4808 task_queue: String,
4809 payload_codec: String,
4810 resume_signal: Option<ResumeSignal>,
4811 ) -> Result<Self> {
4812 Self::new_with_identity(
4813 history,
4814 None,
4815 None,
4816 task_queue,
4817 payload_codec,
4818 resume_signal,
4819 )
4820 }
4821
4822 fn new_with_identity(
4823 history: Vec<HistoryEvent>,
4824 workflow_id: Option<String>,
4825 run_id: Option<String>,
4826 task_queue: String,
4827 payload_codec: String,
4828 resume_signal: Option<ResumeSignal>,
4829 ) -> Result<Self> {
4830 let recorded_commands = recorded_commands(
4831 &history,
4832 &payload_codec,
4833 WorkflowIdentity {
4834 workflow_id: workflow_id.clone(),
4835 run_id: run_id.clone(),
4836 },
4837 )?;
4838 let recorded_continue_as_new = history
4839 .iter()
4840 .filter(|event| event.event_type == "WorkflowContinuedAsNew")
4841 .collect::<Vec<_>>();
4842 if recorded_continue_as_new.len() > 1 {
4843 return Err(invalid_recorded_history(
4844 "duplicate_continue_as_new_transition",
4845 recorded_continue_as_new
4846 .last()
4847 .and_then(|event| durable_event_sequence(event))
4848 .unwrap_or(0),
4849 "one WorkflowContinuedAsNew event",
4850 &format!(
4851 "{} WorkflowContinuedAsNew events",
4852 recorded_continue_as_new.len()
4853 ),
4854 "workflow history records one continue-as-new transition more than once",
4855 ));
4856 }
4857 let recorded_continue_as_new_sequence = recorded_continue_as_new
4858 .first()
4859 .map(|event| {
4860 durable_event_sequence(event).ok_or_else(|| {
4861 Error::NonDeterministicReplay(ReplayFailure::new(
4862 "continue_as_new_sequence_missing",
4863 None,
4864 Some("recorded transition sequence".to_string()),
4865 Some("missing sequence".to_string()),
4866 "WorkflowContinuedAsNew history is missing its recorded sequence",
4867 ))
4868 })
4869 })
4870 .transpose()?;
4871 let event_count = u64::try_from(history.len()).unwrap_or(u64::MAX);
4872 Ok(Self {
4873 history,
4874 workflow_id,
4875 run_id,
4876 task_queue,
4877 payload_codec,
4878 history_budget: WorkflowHistoryBudget {
4879 event_count,
4880 ..WorkflowHistoryBudget::default()
4881 },
4882 resume_signal,
4883 recorded_commands,
4884 recorded_continue_as_new_sequence,
4885 continue_as_new_consumed: false,
4886 command_cursor: 0,
4887 matched_recorded_pending: false,
4888 signal_cursors: HashMap::new(),
4889 version_markers: HashMap::new(),
4890 commands: Vec::new(),
4891 })
4892 }
4893}
4894
4895#[derive(Clone, Debug)]
4896enum RecordedCommand {
4897 Activity {
4898 sequence: u64,
4899 activity_type: Option<String>,
4900 options: Option<RecordedActivityOptions>,
4901 outcome: Option<ActivityOutcome>,
4902 },
4903 Timer {
4904 sequence: u64,
4905 delay_seconds: u64,
4906 fired: bool,
4907 },
4908 ChildWorkflow {
4909 sequence: u64,
4910 workflow_type: Option<String>,
4911 outcome: Option<ChildWorkflowOutcome>,
4912 },
4913 SignalWait {
4914 sequence: u64,
4915 signal_name: Option<String>,
4916 },
4917 SideEffect {
4918 sequence: u64,
4919 value: Value,
4920 },
4921 VersionMarker {
4922 sequence: u64,
4923 change_id: String,
4924 version: i32,
4925 },
4926}
4927
4928#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
4929struct RecordedActivityOptions {
4930 task_queue: RecordedSnapshotValue<Option<String>>,
4931 execution_mode: RecordedSnapshotValue<Option<String>>,
4932 retry_policy: ActivityRetrySnapshot,
4933}
4934
4935#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
4936enum RecordedSnapshotValue<T> {
4937 Unknown,
4939 Known(T),
4940}
4941
4942impl<T: PartialEq> RecordedSnapshotValue<T> {
4943 fn matches_current(&self, current: &Self) -> bool {
4944 match self {
4945 Self::Unknown => true,
4946 Self::Known(recorded) => matches!(current, Self::Known(value) if value == recorded),
4947 }
4948 }
4949}
4950
4951#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
4952struct ActivityRetrySnapshot {
4953 snapshot_version: RecordedSnapshotValue<Option<u64>>,
4954 max_attempts: RecordedSnapshotValue<Option<u64>>,
4955 backoff_seconds: RecordedSnapshotValue<Vec<u64>>,
4956 start_to_close_timeout: RecordedSnapshotValue<Option<u64>>,
4957 schedule_to_start_timeout: RecordedSnapshotValue<Option<u64>>,
4958 schedule_to_close_timeout: RecordedSnapshotValue<Option<u64>>,
4959 heartbeat_timeout: RecordedSnapshotValue<Option<u64>>,
4960 non_retryable_error_types: RecordedSnapshotValue<Vec<String>>,
4961}
4962
4963impl ActivityRetrySnapshot {
4964 fn matches_current(&self, current: &Self) -> bool {
4965 self.snapshot_version
4966 .matches_current(¤t.snapshot_version)
4967 && self.max_attempts.matches_current(¤t.max_attempts)
4968 && self
4969 .backoff_seconds
4970 .matches_current(¤t.backoff_seconds)
4971 && self
4972 .start_to_close_timeout
4973 .matches_current(¤t.start_to_close_timeout)
4974 && self
4975 .schedule_to_start_timeout
4976 .matches_current(¤t.schedule_to_start_timeout)
4977 && self
4978 .schedule_to_close_timeout
4979 .matches_current(¤t.schedule_to_close_timeout)
4980 && self
4981 .heartbeat_timeout
4982 .matches_current(¤t.heartbeat_timeout)
4983 && self
4984 .non_retryable_error_types
4985 .matches_current(¤t.non_retryable_error_types)
4986 }
4987}
4988
4989fn recorded_optional_u64(
4990 object: Option<&serde_json::Map<String, Value>>,
4991 field: &str,
4992) -> RecordedSnapshotValue<Option<u64>> {
4993 match object.and_then(|object| object.get(field)) {
4994 None => RecordedSnapshotValue::Unknown,
4995 Some(Value::Null) => RecordedSnapshotValue::Known(None),
4996 Some(value) => RecordedSnapshotValue::Known(value_as_u64(value)),
4997 }
4998}
4999
5000fn recorded_optional_string(
5001 object: &serde_json::Map<String, Value>,
5002 field: &str,
5003) -> RecordedSnapshotValue<Option<String>> {
5004 match object.get(field) {
5005 None => RecordedSnapshotValue::Unknown,
5006 Some(Value::Null) => RecordedSnapshotValue::Known(None),
5007 Some(value) => RecordedSnapshotValue::Known(value.as_str().map(str::to_string)),
5008 }
5009}
5010
5011fn recorded_activity_retry_snapshot(policy: Option<&Value>) -> ActivityRetrySnapshot {
5012 let policy = policy.and_then(Value::as_object);
5013 let backoff_seconds = policy
5014 .and_then(|policy| policy.get("backoff_seconds"))
5015 .and_then(Value::as_array)
5016 .map(|intervals| intervals.iter().filter_map(value_as_u64).collect())
5017 .map_or(RecordedSnapshotValue::Unknown, RecordedSnapshotValue::Known);
5018 let mut non_retryable_error_types = Vec::new();
5019 for error_type in policy
5020 .and_then(|policy| policy.get("non_retryable_error_types"))
5021 .and_then(Value::as_array)
5022 .into_iter()
5023 .flatten()
5024 .filter_map(Value::as_str)
5025 .map(str::trim)
5026 .filter(|error_type| !error_type.is_empty())
5027 {
5028 if !non_retryable_error_types
5029 .iter()
5030 .any(|recorded| recorded == error_type)
5031 {
5032 non_retryable_error_types.push(error_type.to_string());
5033 }
5034 }
5035
5036 ActivityRetrySnapshot {
5037 snapshot_version: recorded_optional_u64(policy, "snapshot_version"),
5038 max_attempts: recorded_optional_u64(policy, "max_attempts"),
5039 backoff_seconds,
5040 start_to_close_timeout: recorded_optional_u64(policy, "start_to_close_timeout"),
5041 schedule_to_start_timeout: recorded_optional_u64(policy, "schedule_to_start_timeout"),
5042 schedule_to_close_timeout: recorded_optional_u64(policy, "schedule_to_close_timeout"),
5043 heartbeat_timeout: recorded_optional_u64(policy, "heartbeat_timeout"),
5044 non_retryable_error_types: if policy
5045 .is_some_and(|policy| policy.contains_key("non_retryable_error_types"))
5046 {
5047 RecordedSnapshotValue::Known(non_retryable_error_types)
5048 } else {
5049 RecordedSnapshotValue::Unknown
5050 },
5051 }
5052}
5053
5054fn current_activity_retry_snapshot(options: &ValidatedActivityOptions) -> ActivityRetrySnapshot {
5055 let policy = options.retry_policy.as_ref();
5056 let max_attempts = match policy.and_then(|policy| policy.get("max_attempts")) {
5057 Some(Value::Null) => None,
5058 Some(value) => value_as_u64(value),
5059 None => Some(1),
5060 };
5061 let backoff_seconds = policy
5062 .and_then(|policy| policy.get("backoff_seconds"))
5063 .and_then(Value::as_array)
5064 .map(|intervals| intervals.iter().filter_map(value_as_u64).collect())
5065 .unwrap_or_default();
5066 let non_retryable_error_types = policy
5067 .and_then(|policy| policy.get("non_retryable_error_types"))
5068 .and_then(Value::as_array)
5069 .into_iter()
5070 .flatten()
5071 .filter_map(Value::as_str)
5072 .map(str::to_string)
5073 .collect();
5074
5075 ActivityRetrySnapshot {
5076 snapshot_version: RecordedSnapshotValue::Known(Some(1)),
5077 max_attempts: RecordedSnapshotValue::Known(max_attempts),
5078 backoff_seconds: RecordedSnapshotValue::Known(backoff_seconds),
5079 start_to_close_timeout: RecordedSnapshotValue::Known(options.start_to_close_timeout),
5080 schedule_to_start_timeout: RecordedSnapshotValue::Known(options.schedule_to_start_timeout),
5081 schedule_to_close_timeout: RecordedSnapshotValue::Known(options.schedule_to_close_timeout),
5082 heartbeat_timeout: RecordedSnapshotValue::Known(options.heartbeat_timeout),
5083 non_retryable_error_types: RecordedSnapshotValue::Known(non_retryable_error_types),
5084 }
5085}
5086
5087fn activity_options_description(options: &RecordedActivityOptions) -> String {
5088 serde_json::to_string(options).unwrap_or_else(|_| format!("{options:?}"))
5089}
5090
5091impl RecordedCommand {
5092 fn sequence(&self) -> u64 {
5093 match self {
5094 Self::Activity { sequence, .. }
5095 | Self::Timer { sequence, .. }
5096 | Self::ChildWorkflow { sequence, .. }
5097 | Self::SignalWait { sequence, .. }
5098 | Self::SideEffect { sequence, .. }
5099 | Self::VersionMarker { sequence, .. } => *sequence,
5100 }
5101 }
5102
5103 fn shape(&self) -> &'static str {
5104 match self {
5105 Self::Activity { .. } => "activity",
5106 Self::Timer { .. } => "timer",
5107 Self::ChildWorkflow { .. } => "child workflow",
5108 Self::SignalWait { .. } => "signal wait",
5109 Self::SideEffect { .. } => "side effect",
5110 Self::VersionMarker { .. } => "version marker",
5111 }
5112 }
5113}
5114
5115fn ensure_version_supported(
5116 change_id: &str,
5117 version: i32,
5118 min_supported: i32,
5119 max_supported: i32,
5120 sequence: u64,
5121) -> Result<()> {
5122 if (min_supported..=max_supported).contains(&version) {
5123 return Ok(());
5124 }
5125 Err(Error::NonDeterministicReplay(ReplayFailure::new(
5126 "version_marker_incompatible_range",
5127 (sequence != 0).then_some(sequence),
5128 Some(format!("{min_supported}..={max_supported}")),
5129 Some(format!("{change_id}:{version}")),
5130 "recorded workflow version is outside the range supported by current code",
5131 )))
5132}
5133
5134#[derive(Clone, Debug)]
5135struct ResumeSignal {
5136 signal_id: Option<String>,
5137 signal_name: String,
5138 arguments: Vec<Value>,
5139}
5140
5141pub struct ActivityCall {
5142 ctx: WorkflowContext,
5143 activity_type: String,
5144 options: ActivityOptions,
5145 args: Option<Result<Value>>,
5146 scheduled: bool,
5147}
5148
5149impl Future for ActivityCall {
5150 type Output = Result<Value>;
5151
5152 fn poll(mut self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<Self::Output> {
5153 let ctx = self.ctx.clone();
5154 let mut state = match ctx.state.lock() {
5155 Ok(state) => state,
5156 Err(_) => return Poll::Ready(Err(Error::WorkflowStatePoisoned)),
5157 };
5158
5159 if self.scheduled {
5160 return Poll::Pending;
5161 }
5162
5163 let options = match self.options.validate() {
5164 Ok(options) => options,
5165 Err(error) => {
5166 return Poll::Ready(Err(Error::InvalidActivityOptions(error)));
5167 }
5168 };
5169 let task_queue = options
5170 .task_queue
5171 .clone()
5172 .unwrap_or_else(|| state.task_queue.clone());
5173 let current_recorded_options = RecordedActivityOptions {
5174 task_queue: RecordedSnapshotValue::Known(Some(task_queue.clone())),
5175 execution_mode: RecordedSnapshotValue::Known(None),
5178 retry_policy: current_activity_retry_snapshot(&options),
5179 };
5180
5181 if let Some(recorded) = state.recorded_commands.get(state.command_cursor).cloned() {
5182 let sequence = recorded.sequence();
5183 match recorded {
5184 RecordedCommand::Activity {
5185 activity_type,
5186 options: recorded_options,
5187 outcome,
5188 ..
5189 } => {
5190 if let Some(recorded_type) = activity_type {
5191 if recorded_type != self.activity_type {
5192 return Poll::Ready(Err(Error::NonDeterministicReplay(
5193 ReplayFailure::new(
5194 "recorded_command_detail_mismatch",
5195 Some(sequence),
5196 Some(format!("activity:{recorded_type}")),
5197 Some(format!("activity:{}", self.activity_type)),
5198 "recorded activity type differs from the current workflow command",
5199 ),
5200 )));
5201 }
5202 }
5203 if let Some(recorded_options) = recorded_options {
5204 if !recorded_options
5205 .task_queue
5206 .matches_current(¤t_recorded_options.task_queue)
5207 {
5208 return Poll::Ready(Err(Error::NonDeterministicReplay(
5209 ReplayFailure::new(
5210 "activity_task_queue_mismatch",
5211 Some(sequence),
5212 Some(activity_options_description(&recorded_options)),
5213 Some(activity_options_description(¤t_recorded_options)),
5214 "recorded activity task queue differs from the current workflow command",
5215 ),
5216 )));
5217 }
5218 if !recorded_options
5219 .execution_mode
5220 .matches_current(¤t_recorded_options.execution_mode)
5221 {
5222 return Poll::Ready(Err(Error::NonDeterministicReplay(
5223 ReplayFailure::new(
5224 "activity_execution_mode_mismatch",
5225 Some(sequence),
5226 Some(activity_options_description(&recorded_options)),
5227 Some(activity_options_description(¤t_recorded_options)),
5228 "recorded activity execution mode differs from the current workflow command",
5229 ),
5230 )));
5231 }
5232 if !recorded_options
5233 .retry_policy
5234 .matches_current(¤t_recorded_options.retry_policy)
5235 {
5236 return Poll::Ready(Err(Error::NonDeterministicReplay(
5237 ReplayFailure::new(
5238 "activity_retry_policy_mismatch",
5239 Some(sequence),
5240 Some(activity_options_description(&recorded_options)),
5241 Some(activity_options_description(¤t_recorded_options)),
5242 "recorded activity retry policy differs from the current workflow command",
5243 ),
5244 )));
5245 }
5246 }
5247 state.command_cursor += 1;
5248 if let Some(outcome) = outcome {
5249 return Poll::Ready(outcome.map_err(Error::ActivityFailed));
5250 }
5251 state.matched_recorded_pending = true;
5252 self.scheduled = true;
5253 return Poll::Pending;
5254 }
5255 other => {
5256 return Poll::Ready(Err(command_mismatch(
5257 &other,
5258 format!("activity:{}", self.activity_type),
5259 )));
5260 }
5261 }
5262 }
5263
5264 if !self.scheduled {
5265 let args = match self.args.take().unwrap_or(Ok(Value::Null)) {
5266 Ok(args) => args,
5267 Err(error) => return Poll::Ready(Err(error)),
5268 };
5269 let arguments = normalize_arguments(args);
5270 let envelope = match encode_value_envelope(&arguments, &state.payload_codec) {
5271 Ok(envelope) => envelope,
5272 Err(error) => return Poll::Ready(Err(error)),
5273 };
5274
5275 let mut command = serde_json::Map::from_iter([
5276 ("type".to_string(), json!("schedule_activity")),
5277 (
5278 "activity_type".to_string(),
5279 json!(self.activity_type.clone()),
5280 ),
5281 ("queue".to_string(), json!(task_queue)),
5282 ("arguments".to_string(), envelope),
5283 ]);
5284 for (field, value) in [
5285 ("start_to_close_timeout", options.start_to_close_timeout),
5286 (
5287 "schedule_to_start_timeout",
5288 options.schedule_to_start_timeout,
5289 ),
5290 (
5291 "schedule_to_close_timeout",
5292 options.schedule_to_close_timeout,
5293 ),
5294 ("heartbeat_timeout", options.heartbeat_timeout),
5295 ] {
5296 if let Some(value) = value {
5297 command.insert(field.to_string(), json!(value));
5298 }
5299 }
5300 if let Some(retry_policy) = options.retry_policy {
5301 command.insert("retry_policy".to_string(), retry_policy);
5302 }
5303 state.commands.push(Value::Object(command));
5304 self.scheduled = true;
5305 }
5306
5307 Poll::Pending
5308 }
5309}
5310
5311pub struct TimerCall {
5313 ctx: WorkflowContext,
5314 delay_seconds: Option<u64>,
5315 scheduled: bool,
5316 matched_pending: bool,
5317}
5318
5319impl Future for TimerCall {
5320 type Output = Result<()>;
5321
5322 fn poll(mut self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<Self::Output> {
5323 if self.matched_pending {
5324 return Poll::Pending;
5325 }
5326
5327 let ctx = self.ctx.clone();
5328 let Some(requested_delay) = self.delay_seconds else {
5329 return Poll::Ready(Err(Error::TimerDurationOverflow));
5330 };
5331 let mut state = match ctx.state.lock() {
5332 Ok(state) => state,
5333 Err(_) => return Poll::Ready(Err(Error::WorkflowStatePoisoned)),
5334 };
5335
5336 if let Some(recorded) = state.recorded_commands.get(state.command_cursor).cloned() {
5337 match recorded {
5338 RecordedCommand::Timer {
5339 sequence,
5340 delay_seconds,
5341 fired,
5342 ..
5343 } => {
5344 if delay_seconds != requested_delay {
5345 return Poll::Ready(Err(Error::NonDeterministicReplay(
5346 ReplayFailure::new(
5347 "timer_delay_mismatch",
5348 Some(sequence),
5349 Some(format!("timer:{delay_seconds}s")),
5350 Some(format!("timer:{requested_delay}s")),
5351 "recorded timer delay differs from the current workflow command",
5352 ),
5353 )));
5354 }
5355 state.command_cursor += 1;
5356 if fired {
5357 return Poll::Ready(Ok(()));
5358 }
5359 state.matched_recorded_pending = true;
5360 self.scheduled = true;
5361 self.matched_pending = true;
5362 return Poll::Pending;
5363 }
5364 other => return Poll::Ready(Err(command_mismatch(&other, "timer"))),
5365 }
5366 }
5367
5368 if !self.scheduled {
5369 state.commands.push(json!({
5370 "type": "start_timer",
5371 "delay_seconds": requested_delay,
5372 }));
5373 self.scheduled = true;
5374 }
5375
5376 Poll::Pending
5377 }
5378}
5379
5380pub struct ChildWorkflowCall {
5382 ctx: WorkflowContext,
5383 workflow_type: String,
5384 options: ChildWorkflowOptions,
5385 args: Option<Result<Value>>,
5386 scheduled: bool,
5387 matched_pending: bool,
5388}
5389
5390impl Future for ChildWorkflowCall {
5391 type Output = Result<ChildWorkflowResult>;
5392
5393 fn poll(mut self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<Self::Output> {
5394 if self.matched_pending {
5395 return Poll::Pending;
5396 }
5397
5398 let ctx = self.ctx.clone();
5399 let mut state = match ctx.state.lock() {
5400 Ok(state) => state,
5401 Err(_) => return Poll::Ready(Err(Error::WorkflowStatePoisoned)),
5402 };
5403
5404 if let Some(recorded) = state.recorded_commands.get(state.command_cursor).cloned() {
5405 let sequence = recorded.sequence();
5406 match recorded {
5407 RecordedCommand::ChildWorkflow {
5408 workflow_type,
5409 outcome,
5410 ..
5411 } => {
5412 if let Some(recorded_type) = workflow_type {
5413 if recorded_type != self.workflow_type {
5414 return Poll::Ready(Err(Error::NonDeterministicReplay(
5415 ReplayFailure::new(
5416 "recorded_command_detail_mismatch",
5417 Some(sequence),
5418 Some(format!("child workflow:{recorded_type}")),
5419 Some(format!("child workflow:{}", self.workflow_type)),
5420 "recorded child workflow type differs from the current workflow command",
5421 ),
5422 )));
5423 }
5424 }
5425 state.command_cursor += 1;
5426 if let Some(outcome) = outcome {
5427 return Poll::Ready(outcome.map_err(Error::ChildWorkflowFailed));
5428 }
5429 state.matched_recorded_pending = true;
5430 self.scheduled = true;
5431 self.matched_pending = true;
5432 return Poll::Pending;
5433 }
5434 other => {
5435 return Poll::Ready(Err(command_mismatch(
5436 &other,
5437 format!("child workflow:{}", self.workflow_type),
5438 )));
5439 }
5440 }
5441 }
5442
5443 if !self.scheduled {
5444 if self.options.task_queue.trim().is_empty() {
5445 return Poll::Ready(Err(Error::InvalidChildWorkflowOptions(
5446 "task_queue must not be empty".to_string(),
5447 )));
5448 }
5449 for (name, value) in [
5450 (
5451 "execution_timeout_seconds",
5452 self.options.execution_timeout_seconds,
5453 ),
5454 ("run_timeout_seconds", self.options.run_timeout_seconds),
5455 ] {
5456 if value == Some(0) {
5457 return Poll::Ready(Err(Error::InvalidChildWorkflowOptions(format!(
5458 "{name} must be at least 1"
5459 ))));
5460 }
5461 }
5462
5463 let args = match self.args.take().unwrap_or(Ok(Value::Null)) {
5464 Ok(args) => args,
5465 Err(error) => return Poll::Ready(Err(error)),
5466 };
5467 let arguments =
5468 match encode_value_envelope(&normalize_arguments(args), &state.payload_codec) {
5469 Ok(arguments) => arguments,
5470 Err(error) => return Poll::Ready(Err(error)),
5471 };
5472 let mut command = json!({
5473 "type": "start_child_workflow",
5474 "workflow_type": self.workflow_type,
5475 "queue": self.options.task_queue,
5476 "parent_close_policy": self.options.parent_close_policy.as_str(),
5477 "arguments": arguments,
5478 });
5479 let object = command
5480 .as_object_mut()
5481 .expect("child workflow command is always an object");
5482 if let Some(policy) = &self.options.retry_policy {
5483 let mut retry_policy = serde_json::Map::new();
5484 if let Some(max_attempts) = policy.max_attempts {
5485 if max_attempts == 0 {
5486 return Poll::Ready(Err(Error::InvalidChildWorkflowOptions(
5487 "retry_policy.max_attempts must be at least 1".to_string(),
5488 )));
5489 }
5490 retry_policy.insert("max_attempts".to_string(), json!(max_attempts));
5491 }
5492 if !policy.backoff_seconds.is_empty() {
5493 retry_policy
5494 .insert("backoff_seconds".to_string(), json!(policy.backoff_seconds));
5495 }
5496 if !policy.non_retryable_error_types.is_empty() {
5497 retry_policy.insert(
5498 "non_retryable_error_types".to_string(),
5499 json!(policy.non_retryable_error_types),
5500 );
5501 }
5502 if retry_policy.is_empty() {
5503 return Poll::Ready(Err(Error::InvalidChildWorkflowOptions(
5504 "retry_policy must configure at least one field".to_string(),
5505 )));
5506 }
5507 object.insert("retry_policy".to_string(), Value::Object(retry_policy));
5508 }
5509 if let Some(seconds) = self.options.execution_timeout_seconds {
5510 object.insert("execution_timeout_seconds".to_string(), json!(seconds));
5511 }
5512 if let Some(seconds) = self.options.run_timeout_seconds {
5513 object.insert("run_timeout_seconds".to_string(), json!(seconds));
5514 }
5515 state.commands.push(command);
5516 self.scheduled = true;
5517 }
5518
5519 Poll::Pending
5520 }
5521}
5522
5523fn command_mismatch(recorded: &RecordedCommand, actual: impl Into<String>) -> Error {
5524 Error::NonDeterministicReplay(ReplayFailure::new(
5525 "recorded_command_mismatch",
5526 Some(recorded.sequence()),
5527 Some(recorded.shape().to_string()),
5528 Some(actual.into()),
5529 "current workflow command does not match the recorded durable command sequence",
5530 ))
5531}
5532
5533pub struct SignalCall {
5534 ctx: WorkflowContext,
5535 signal_name: String,
5536 opened_wait: bool,
5537 matched_pending: bool,
5538}
5539
5540impl Future for SignalCall {
5541 type Output = Result<Vec<Value>>;
5542
5543 fn poll(mut self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<Self::Output> {
5544 if self.matched_pending {
5545 return Poll::Pending;
5546 }
5547
5548 let ctx = self.ctx.clone();
5549 let mut state = match ctx.state.lock() {
5550 Ok(state) => state,
5551 Err(_) => return Poll::Ready(Err(Error::WorkflowStatePoisoned)),
5552 };
5553
5554 let signals = match signal_values(
5555 &state.history,
5556 &self.signal_name,
5557 &state.payload_codec,
5558 state.resume_signal.as_ref(),
5559 ) {
5560 Ok(signals) => signals,
5561 Err(error) => return Poll::Ready(Err(error)),
5562 };
5563 let cursor = *state.signal_cursors.get(&self.signal_name).unwrap_or(&0);
5564
5565 if let Some(recorded) = state.recorded_commands.get(state.command_cursor).cloned() {
5566 match recorded {
5567 RecordedCommand::SignalWait {
5568 sequence,
5569 signal_name,
5570 } => {
5571 if let Some(recorded_name) = signal_name {
5572 if recorded_name != self.signal_name {
5573 return Poll::Ready(Err(Error::NonDeterministicReplay(
5574 ReplayFailure::new(
5575 "recorded_command_detail_mismatch",
5576 Some(sequence),
5577 Some(format!("signal wait:{recorded_name}")),
5578 Some(format!("signal wait:{}", self.signal_name)),
5579 "recorded signal name differs from the current workflow command",
5580 ),
5581 )));
5582 }
5583 }
5584
5585 state.command_cursor += 1;
5586 if cursor < signals.len() {
5587 state
5588 .signal_cursors
5589 .insert(self.signal_name.clone(), cursor + 1);
5590 return Poll::Ready(Ok(signals[cursor].clone()));
5591 }
5592
5593 state.matched_recorded_pending = true;
5594 self.opened_wait = true;
5595 self.matched_pending = true;
5596 return Poll::Pending;
5597 }
5598 other => {
5599 return Poll::Ready(Err(command_mismatch(
5600 &other,
5601 format!("signal wait:{}", self.signal_name),
5602 )));
5603 }
5604 }
5605 }
5606
5607 if cursor < signals.len() {
5608 state
5609 .signal_cursors
5610 .insert(self.signal_name.clone(), cursor + 1);
5611 return Poll::Ready(Ok(signals[cursor].clone()));
5612 }
5613
5614 if !self.opened_wait {
5615 state.commands.push(json!({
5616 "type": "open_condition_wait",
5617 "condition_key": format!("signal:{}", self.signal_name)
5618 }));
5619 self.opened_wait = true;
5620 }
5621
5622 Poll::Pending
5623 }
5624}
5625
5626#[derive(Clone, Debug)]
5627pub struct ActivityContext {
5628 client: Client,
5629 pub task_id: String,
5630 pub activity_attempt_id: String,
5631 pub lease_owner: String,
5632 pub activity_type: String,
5633 pub attempt_number: u64,
5634 pub task_queue: String,
5635 pub worker_id: String,
5636}
5637
5638impl ActivityContext {
5639 pub async fn heartbeat<T: Serialize>(&self, details: T) -> Result<ActivityHeartbeatResponse> {
5640 self.client
5641 .heartbeat_activity_task(
5642 &self.task_id,
5643 &self.activity_attempt_id,
5644 &self.lease_owner,
5645 serde_json::to_value(details)?,
5646 )
5647 .await
5648 }
5649}
5650
5651fn decode_task_arguments(value: Option<&Value>, codec: &str) -> Result<Value> {
5652 match value {
5653 Some(value) => Ok(normalize_arguments(decode_wire_value(value, codec)?)),
5654 None => Ok(Value::Array(Vec::new())),
5655 }
5656}
5657
5658fn decode_resume_signal(task: &WorkflowTask) -> Result<Option<ResumeSignal>> {
5659 let Some(signal_name) = task
5660 .signal_name
5661 .as_deref()
5662 .filter(|value| !value.is_empty())
5663 else {
5664 return Ok(None);
5665 };
5666 let Some(arguments) = task.signal_arguments.as_ref() else {
5667 return Ok(None);
5668 };
5669
5670 let decoded = normalize_arguments(decode_wire_value(arguments, &task.payload_codec)?);
5671 let Value::Array(arguments) = decoded else {
5672 unreachable!("normalize_arguments always returns an array");
5673 };
5674
5675 Ok(Some(ResumeSignal {
5676 signal_id: task.workflow_signal_id.clone(),
5677 signal_name: signal_name.to_string(),
5678 arguments,
5679 }))
5680}
5681
5682fn normalize_arguments(value: Value) -> Value {
5683 match value {
5684 Value::Null => Value::Array(Vec::new()),
5685 Value::Array(_) => value,
5686 other => Value::Array(vec![other]),
5687 }
5688}
5689
5690fn recorded_commands(
5691 events: &[HistoryEvent],
5692 fallback_codec: &str,
5693 parent: WorkflowIdentity,
5694) -> Result<Vec<RecordedCommand>> {
5695 let mut events_by_sequence: BTreeMap<u64, Vec<&HistoryEvent>> = BTreeMap::new();
5696
5697 for event in events {
5698 let is_activity = matches!(
5699 event.event_type.as_str(),
5700 "ActivityScheduled"
5701 | "ActivityStarted"
5702 | "ActivityHeartbeatRecorded"
5703 | "ActivityRetryScheduled"
5704 | "ActivityCompleted"
5705 | "ActivityFailed"
5706 | "ActivityCancelled"
5707 | "ActivityTimedOut"
5708 );
5709 let is_workflow_timer = matches!(
5710 event.event_type.as_str(),
5711 "TimerScheduled" | "TimerCancelled" | "TimerFired"
5712 ) && !is_internal_timer_event(event);
5713 let is_child_workflow = matches!(
5714 event.event_type.as_str(),
5715 "ChildWorkflowScheduled"
5716 | "ChildRunCompleted"
5717 | "ChildRunFailed"
5718 | "ChildRunCancelled"
5719 | "ChildRunTerminated"
5720 );
5721 let is_signal_wait = is_recorded_signal_wait_event(event);
5722 let is_side_effect = event.event_type == "SideEffectRecorded";
5723 let is_version_marker = event.event_type == "VersionMarkerRecorded";
5724 if !is_activity
5725 && !is_workflow_timer
5726 && !is_child_workflow
5727 && !is_signal_wait
5728 && !is_side_effect
5729 && !is_version_marker
5730 {
5731 continue;
5732 }
5733
5734 let sequence = durable_event_sequence(event).ok_or_else(|| {
5735 Error::NonDeterministicReplay(ReplayFailure::new(
5736 "durable_command_sequence_missing",
5737 None,
5738 Some("positive workflow sequence".to_string()),
5739 Some(event.event_type.clone()),
5740 "durable command history event has no workflow sequence",
5741 ))
5742 })?;
5743 if sequence == 0 {
5744 return Err(Error::NonDeterministicReplay(ReplayFailure::new(
5745 "durable_command_sequence_invalid",
5746 Some(sequence),
5747 Some("positive workflow sequence".to_string()),
5748 Some(sequence.to_string()),
5749 "durable command history uses an invalid workflow sequence",
5750 )));
5751 }
5752 events_by_sequence.entry(sequence).or_default().push(event);
5753 }
5754
5755 let commands: Vec<RecordedCommand> = events_by_sequence
5756 .into_iter()
5757 .map(|(sequence, sequence_events)| {
5758 let activity_events: Vec<_> = sequence_events
5759 .iter()
5760 .copied()
5761 .filter(|event| event.event_type.starts_with("Activity"))
5762 .collect();
5763 let timer_events: Vec<_> = sequence_events
5764 .iter()
5765 .copied()
5766 .filter(|event| event.event_type.starts_with("Timer"))
5767 .collect();
5768 let child_events: Vec<_> = sequence_events
5769 .iter()
5770 .copied()
5771 .filter(|event| {
5772 event.event_type == "ChildWorkflowScheduled"
5773 || event.event_type.starts_with("ChildRun")
5774 })
5775 .collect();
5776 let signal_wait_events: Vec<_> = sequence_events
5777 .iter()
5778 .copied()
5779 .filter(|event| is_recorded_signal_wait_event(event))
5780 .collect();
5781 let side_effect_events: Vec<_> = sequence_events
5782 .iter()
5783 .copied()
5784 .filter(|event| event.event_type == "SideEffectRecorded")
5785 .collect();
5786 let version_marker_events: Vec<_> = sequence_events
5787 .iter()
5788 .copied()
5789 .filter(|event| event.event_type == "VersionMarkerRecorded")
5790 .collect();
5791
5792 let command_kind_count = usize::from(!activity_events.is_empty())
5793 + usize::from(!timer_events.is_empty())
5794 + usize::from(!child_events.is_empty())
5795 + usize::from(!signal_wait_events.is_empty())
5796 + usize::from(!side_effect_events.is_empty())
5797 + usize::from(!version_marker_events.is_empty());
5798 if command_kind_count > 1 {
5799 let actual = [
5800 (!activity_events.is_empty()).then_some("activity"),
5801 (!timer_events.is_empty()).then_some("timer"),
5802 (!child_events.is_empty()).then_some("child workflow"),
5803 (!signal_wait_events.is_empty()).then_some("signal wait"),
5804 (!side_effect_events.is_empty()).then_some("side effect"),
5805 (!version_marker_events.is_empty()).then_some("version marker"),
5806 ]
5807 .into_iter()
5808 .flatten()
5809 .collect::<Vec<_>>()
5810 .join(" and ");
5811 return Err(invalid_recorded_history(
5812 "durable_command_sequence_collision",
5813 sequence,
5814 "one durable command kind",
5815 &actual,
5816 "one workflow sequence records more than one durable command kind",
5817 ));
5818 }
5819
5820 if !activity_events.is_empty() {
5821 let scheduled_count = activity_events
5822 .iter()
5823 .filter(|event| event.event_type == "ActivityScheduled")
5824 .count();
5825 if scheduled_count > 1 {
5826 return Err(invalid_recorded_history(
5827 "duplicate_activity_schedule",
5828 sequence,
5829 "at most one ActivityScheduled event",
5830 "multiple ActivityScheduled events",
5831 "activity history schedules more than one command at one workflow sequence",
5832 ));
5833 }
5834 let activity_type = activity_events.iter().find_map(|event| {
5835 event
5836 .payload
5837 .get("activity_type")
5838 .or_else(|| event.payload.get("activity_name"))
5839 .and_then(Value::as_str)
5840 .map(str::to_string)
5841 });
5842 if activity_events.iter().filter_map(|event| {
5843 event
5844 .payload
5845 .get("activity_type")
5846 .or_else(|| event.payload.get("activity_name"))
5847 .and_then(Value::as_str)
5848 }).any(|candidate| Some(candidate) != activity_type.as_deref()) {
5849 return Err(invalid_recorded_history(
5850 "activity_identity_mismatch",
5851 sequence,
5852 activity_type.as_deref().unwrap_or("one activity identity"),
5853 "conflicting activity identities",
5854 "activity lifecycle events at one workflow sequence disagree on identity",
5855 ));
5856 }
5857 let terminal: Vec<_> = activity_events
5858 .iter()
5859 .copied()
5860 .filter(|event| {
5861 matches!(
5862 event.event_type.as_str(),
5863 "ActivityCompleted"
5864 | "ActivityFailed"
5865 | "ActivityCancelled"
5866 | "ActivityTimedOut"
5867 )
5868 })
5869 .collect();
5870 if terminal.len() > 1 {
5871 return Err(invalid_recorded_history(
5872 "duplicate_activity_terminal_event",
5873 sequence,
5874 "at most one terminal activity event",
5875 "multiple terminal activity events",
5876 "activity history settles one command more than once",
5877 ));
5878 }
5879 let outcome = terminal
5880 .first()
5881 .map(|event| activity_outcome(event, fallback_codec, activity_type.clone()))
5882 .transpose()?;
5883 let options = activity_events
5884 .iter()
5885 .find(|event| event.event_type == "ActivityScheduled")
5886 .and_then(|event| event.payload.get("activity"))
5887 .and_then(Value::as_object)
5888 .map(|activity| RecordedActivityOptions {
5889 task_queue: recorded_optional_string(activity, "queue"),
5890 execution_mode: recorded_optional_string(activity, "execution_mode"),
5891 retry_policy: recorded_activity_retry_snapshot(
5892 activity.get("retry_policy"),
5893 ),
5894 });
5895 return Ok(RecordedCommand::Activity {
5896 sequence,
5897 activity_type,
5898 options,
5899 outcome,
5900 });
5901 }
5902
5903 if !child_events.is_empty() {
5904 let scheduled: Vec<_> = child_events
5905 .iter()
5906 .copied()
5907 .filter(|event| event.event_type == "ChildWorkflowScheduled")
5908 .collect();
5909 if scheduled.len() != 1 {
5910 return Err(invalid_recorded_history(
5911 "child_workflow_schedule_missing_or_duplicate",
5912 sequence,
5913 "one ChildWorkflowScheduled event",
5914 &format!("{} ChildWorkflowScheduled events", scheduled.len()),
5915 "child workflow replay requires exactly one recorded schedule event",
5916 ));
5917 }
5918 let workflow_type = child_events.iter().find_map(|event| {
5919 event
5920 .payload
5921 .get("child_workflow_type")
5922 .or_else(|| event.payload.get("workflow_type"))
5923 .and_then(Value::as_str)
5924 .filter(|value| !value.is_empty())
5925 .map(str::to_string)
5926 });
5927 if child_events
5928 .iter()
5929 .filter_map(|event| {
5930 event
5931 .payload
5932 .get("child_workflow_type")
5933 .or_else(|| event.payload.get("workflow_type"))
5934 .and_then(Value::as_str)
5935 })
5936 .any(|candidate| Some(candidate) != workflow_type.as_deref())
5937 {
5938 return Err(invalid_recorded_history(
5939 "child_workflow_identity_mismatch",
5940 sequence,
5941 workflow_type
5942 .as_deref()
5943 .unwrap_or("one child workflow type"),
5944 "conflicting child workflow types",
5945 "child workflow lifecycle events at one sequence disagree on type",
5946 ));
5947 }
5948 let mut outcomes = child_workflow_outcomes(
5949 &child_events.iter().map(|event| (*event).clone()).collect::<Vec<_>>(),
5950 fallback_codec,
5951 parent.clone(),
5952 )?;
5953 if outcomes.len() > 1 {
5954 return Err(invalid_recorded_history(
5955 "duplicate_child_workflow_terminal_event",
5956 sequence,
5957 "at most one terminal child event",
5958 "multiple terminal child events",
5959 "child workflow history settles one command more than once",
5960 ));
5961 }
5962 return Ok(RecordedCommand::ChildWorkflow {
5963 sequence,
5964 workflow_type,
5965 outcome: outcomes.pop(),
5966 });
5967 }
5968
5969 if !signal_wait_events.is_empty() {
5970 let opened_count = signal_wait_events
5971 .iter()
5972 .filter(|event| {
5973 matches!(
5974 event.event_type.as_str(),
5975 "SignalWaitOpened" | "ConditionWaitOpened"
5976 )
5977 })
5978 .count();
5979 if opened_count > 1 {
5980 return Err(invalid_recorded_history(
5981 "duplicate_signal_wait_open",
5982 sequence,
5983 "at most one signal wait open event",
5984 "multiple signal wait open events",
5985 "signal history opens more than one durable wait at one workflow sequence",
5986 ));
5987 }
5988
5989 let signal_name = signal_wait_events
5990 .iter()
5991 .find_map(|event| recorded_signal_wait_name(event));
5992 if signal_wait_events
5993 .iter()
5994 .filter_map(|event| recorded_signal_wait_name(event))
5995 .any(|candidate| Some(candidate.as_str()) != signal_name.as_deref())
5996 {
5997 return Err(invalid_recorded_history(
5998 "signal_wait_identity_mismatch",
5999 sequence,
6000 signal_name.as_deref().unwrap_or("one signal name"),
6001 "conflicting signal names",
6002 "signal wait lifecycle events at one workflow sequence disagree on identity",
6003 ));
6004 }
6005 return Ok(RecordedCommand::SignalWait {
6006 sequence,
6007 signal_name,
6008 });
6009 }
6010
6011 if !side_effect_events.is_empty() {
6012 if side_effect_events.len() != 1 {
6013 return Err(invalid_recorded_history(
6014 "duplicate_side_effect_record",
6015 sequence,
6016 "one SideEffectRecorded event",
6017 &format!("{} SideEffectRecorded events", side_effect_events.len()),
6018 "side-effect history records one workflow command more than once",
6019 ));
6020 }
6021 let event = side_effect_events[0];
6022 let result = event.payload.get("result").ok_or_else(|| {
6023 invalid_recorded_history(
6024 "side_effect_result_missing",
6025 sequence,
6026 "recorded result payload",
6027 "missing result",
6028 "side-effect history is missing its recorded value",
6029 )
6030 })?;
6031 let has_published_envelope = result.as_str().is_some()
6032 || result.as_object().is_some_and(|envelope| {
6033 envelope.get("codec").and_then(Value::as_str).is_some()
6034 && envelope.get("blob").and_then(Value::as_str).is_some()
6035 });
6036 if !has_published_envelope {
6037 return Err(invalid_recorded_history(
6038 "side_effect_payload_malformed",
6039 sequence,
6040 "payload blob or {codec, blob} envelope",
6041 &result.to_string(),
6042 "side-effect history result does not use a published payload envelope",
6043 ));
6044 }
6045 let codec = event
6046 .payload
6047 .get("payload_codec")
6048 .and_then(Value::as_str)
6049 .unwrap_or(fallback_codec);
6050 let value = decode_wire_value(result, codec).map_err(|error| {
6051 invalid_recorded_history(
6052 "side_effect_payload_incompatible",
6053 sequence,
6054 &format!("valid {codec} payload envelope"),
6055 &error.to_string(),
6056 "side-effect history payload cannot be decoded with its recorded codec",
6057 )
6058 })?;
6059 return Ok(RecordedCommand::SideEffect { sequence, value });
6060 }
6061
6062 if !version_marker_events.is_empty() {
6063 if version_marker_events.len() != 1 {
6064 return Err(invalid_recorded_history(
6065 "duplicate_version_marker_record",
6066 sequence,
6067 "one VersionMarkerRecorded event",
6068 &format!("{} VersionMarkerRecorded events", version_marker_events.len()),
6069 "version-marker history records one workflow command more than once",
6070 ));
6071 }
6072 let payload = &version_marker_events[0].payload;
6073 let change_id = payload
6074 .get("change_id")
6075 .and_then(Value::as_str)
6076 .filter(|value| !value.is_empty())
6077 .map(str::to_string)
6078 .ok_or_else(|| {
6079 invalid_recorded_history(
6080 "version_marker_field_missing",
6081 sequence,
6082 "non-empty change_id",
6083 "missing or invalid change_id",
6084 "version-marker history is missing its stable change ID",
6085 )
6086 })?;
6087 let version = required_version_i32(payload, "version", sequence)?;
6088 let min_supported = required_version_i32(payload, "min_supported", sequence)?;
6089 let max_supported = required_version_i32(payload, "max_supported", sequence)?;
6090 if min_supported > max_supported || version < min_supported || version > max_supported {
6091 return Err(invalid_recorded_history(
6092 "version_marker_history_range_invalid",
6093 sequence,
6094 "min_supported <= version <= max_supported",
6095 &format!("{min_supported} <= {version} <= {max_supported}"),
6096 "recorded version marker contains an internally incompatible range",
6097 ));
6098 }
6099 return Ok(RecordedCommand::VersionMarker {
6100 sequence,
6101 change_id,
6102 version,
6103 });
6104 }
6105
6106 let scheduled: Vec<_> = timer_events
6107 .iter()
6108 .copied()
6109 .filter(|event| event.event_type == "TimerScheduled")
6110 .collect();
6111 let fired: Vec<_> = timer_events
6112 .iter()
6113 .copied()
6114 .filter(|event| event.event_type == "TimerFired")
6115 .collect();
6116 if scheduled.len() != 1 {
6117 return Err(invalid_recorded_history(
6118 "timer_schedule_missing_or_duplicate",
6119 sequence,
6120 "one TimerScheduled event",
6121 &format!("{} TimerScheduled events", scheduled.len()),
6122 "timer replay requires exactly one recorded schedule event",
6123 ));
6124 }
6125 if fired.len() > 1 {
6126 return Err(invalid_recorded_history(
6127 "duplicate_timer_fire",
6128 sequence,
6129 "at most one TimerFired event",
6130 "multiple TimerFired events",
6131 "timer history contains more than one fire event for a workflow sequence",
6132 ));
6133 }
6134
6135 let scheduled = scheduled[0];
6136 let timer_id = required_history_string(scheduled, "timer_id", sequence)?;
6137 let delay_seconds = required_history_u64(scheduled, "delay_seconds", sequence)?;
6138 if let Some(fired) = fired.first() {
6139 let fired_timer_id = required_history_string(fired, "timer_id", sequence)?;
6140 if fired_timer_id != timer_id {
6141 return Err(invalid_recorded_history(
6142 "timer_identity_mismatch",
6143 sequence,
6144 &timer_id,
6145 &fired_timer_id,
6146 "TimerFired does not correspond to the recorded TimerScheduled event",
6147 ));
6148 }
6149 let fired_delay = required_history_u64(fired, "delay_seconds", sequence)?;
6150 if fired_delay != delay_seconds {
6151 return Err(invalid_recorded_history(
6152 "timer_history_delay_mismatch",
6153 sequence,
6154 &delay_seconds.to_string(),
6155 &fired_delay.to_string(),
6156 "TimerScheduled and TimerFired record different delays",
6157 ));
6158 }
6159 }
6160
6161 Ok(RecordedCommand::Timer {
6162 sequence,
6163 delay_seconds,
6164 fired: !fired.is_empty(),
6165 })
6166 })
6167 .collect::<Result<_>>()?;
6168
6169 for (index, command) in commands.iter().enumerate() {
6170 let expected = u64::try_from(index)
6171 .ok()
6172 .and_then(|index| index.checked_add(1))
6173 .ok_or_else(|| {
6174 invalid_recorded_history(
6175 "durable_command_sequence_invalid",
6176 command.sequence(),
6177 "representable positive workflow sequence",
6178 &command.sequence().to_string(),
6179 "durable command history exceeds the supported workflow sequence range",
6180 )
6181 })?;
6182 if command.sequence() != expected {
6183 return Err(invalid_recorded_history(
6184 "durable_command_sequence_mismatch",
6185 command.sequence(),
6186 &expected.to_string(),
6187 &command.sequence().to_string(),
6188 "durable command history does not follow the recorded workflow command sequence",
6189 ));
6190 }
6191 }
6192
6193 let mut marker_sequences = HashMap::new();
6194 for command in &commands {
6195 if let RecordedCommand::VersionMarker {
6196 sequence,
6197 change_id,
6198 ..
6199 } = command
6200 {
6201 if let Some(first_sequence) = marker_sequences.insert(change_id.clone(), *sequence) {
6202 return Err(invalid_recorded_history(
6203 "duplicate_version_marker",
6204 *sequence,
6205 &format!("one marker for change ID {change_id:?}"),
6206 &format!("markers at sequences {first_sequence} and {sequence}"),
6207 "workflow history contains duplicate markers for one stable change ID",
6208 ));
6209 }
6210 }
6211 }
6212
6213 Ok(commands)
6214}
6215
6216fn required_version_i32(payload: &Value, field: &str, sequence: u64) -> Result<i32> {
6217 payload
6218 .get(field)
6219 .and_then(Value::as_i64)
6220 .and_then(|value| i32::try_from(value).ok())
6221 .ok_or_else(|| {
6222 invalid_recorded_history(
6223 "version_marker_field_missing",
6224 sequence,
6225 &format!("integer {field}"),
6226 "missing or out-of-range integer",
6227 "version-marker history is missing a required integer field",
6228 )
6229 })
6230}
6231
6232fn durable_event_sequence(event: &HistoryEvent) -> Option<u64> {
6233 event
6234 .payload
6235 .get("sequence")
6236 .or_else(|| event.payload.get("workflow_sequence"))
6237 .or_else(|| event.raw.get("sequence"))
6238 .or_else(|| event.raw.get("workflow_sequence"))
6239 .and_then(value_as_u64)
6240}
6241
6242fn is_internal_timer_event(event: &HistoryEvent) -> bool {
6243 matches!(
6244 event
6245 .payload
6246 .get("timer_kind")
6247 .or_else(|| event.raw.get("timer_kind"))
6248 .and_then(Value::as_str),
6249 Some("condition_timeout" | "signal_timeout")
6250 )
6251}
6252
6253fn recorded_signal_wait_name(event: &HistoryEvent) -> Option<String> {
6254 match event.event_type.as_str() {
6255 "SignalWaitOpened" | "SignalApplied" => event
6256 .payload
6257 .get("signal_name")
6258 .or_else(|| event.raw.get("signal_name"))
6259 .and_then(Value::as_str)
6260 .filter(|value| !value.is_empty())
6261 .map(str::to_string),
6262 "ConditionWaitOpened" | "ConditionWaitSatisfied" | "ConditionWaitTimedOut" => event
6263 .payload
6264 .get("condition_key")
6265 .or_else(|| event.raw.get("condition_key"))
6266 .and_then(Value::as_str)
6267 .and_then(|key| key.strip_prefix("signal:"))
6268 .filter(|value| !value.is_empty())
6269 .map(str::to_string),
6270 _ => None,
6271 }
6272}
6273
6274fn is_recorded_signal_wait_event(event: &HistoryEvent) -> bool {
6275 match event.event_type.as_str() {
6276 "SignalWaitOpened" | "SignalApplied" => true,
6277 "ConditionWaitOpened" | "ConditionWaitSatisfied" | "ConditionWaitTimedOut" => event
6278 .payload
6279 .get("condition_key")
6280 .or_else(|| event.raw.get("condition_key"))
6281 .and_then(Value::as_str)
6282 .is_some_and(|key| key.starts_with("signal:")),
6283 _ => false,
6284 }
6285}
6286
6287fn required_history_string(event: &HistoryEvent, field: &str, sequence: u64) -> Result<String> {
6288 event
6289 .payload
6290 .get(field)
6291 .and_then(Value::as_str)
6292 .filter(|value| !value.is_empty())
6293 .map(str::to_string)
6294 .ok_or_else(|| {
6295 invalid_recorded_history(
6296 "timer_history_field_missing",
6297 sequence,
6298 field,
6299 &event.event_type,
6300 "timer history is missing a required identity field",
6301 )
6302 })
6303}
6304
6305fn required_history_u64(event: &HistoryEvent, field: &str, sequence: u64) -> Result<u64> {
6306 event
6307 .payload
6308 .get(field)
6309 .and_then(value_as_u64)
6310 .ok_or_else(|| {
6311 invalid_recorded_history(
6312 "timer_history_field_missing",
6313 sequence,
6314 field,
6315 &event.event_type,
6316 "timer history is missing a required numeric field",
6317 )
6318 })
6319}
6320
6321fn invalid_recorded_history(
6322 reason: &str,
6323 sequence: u64,
6324 expected: &str,
6325 actual: &str,
6326 message: &str,
6327) -> Error {
6328 Error::NonDeterministicReplay(ReplayFailure::new(
6329 reason,
6330 Some(sequence),
6331 Some(expected.to_string()),
6332 Some(actual.to_string()),
6333 message,
6334 ))
6335}
6336
6337type ActivityOutcome = std::result::Result<Value, ActivityFailure>;
6338
6339fn activity_outcome(
6340 event: &HistoryEvent,
6341 fallback_codec: &str,
6342 recorded_activity_type: Option<String>,
6343) -> Result<ActivityOutcome> {
6344 if event.event_type == "ActivityCompleted" {
6345 let codec = event
6346 .payload
6347 .get("payload_codec")
6348 .and_then(Value::as_str)
6349 .unwrap_or(fallback_codec);
6350 return Ok(Ok(decode_wire_value(
6351 event.payload.get("result").unwrap_or(&Value::Null),
6352 codec,
6353 )?));
6354 }
6355
6356 let payload = &event.payload;
6357 let (kind, fallback_reason, fallback_message) = match event.event_type.as_str() {
6358 "ActivityFailed" => (ActivityFailureKind::Failed, "activity", "activity failed"),
6359 "ActivityCancelled" => (
6360 ActivityFailureKind::Cancelled,
6361 "cancelled",
6362 "activity was cancelled",
6363 ),
6364 "ActivityTimedOut" => (
6365 ActivityFailureKind::TimedOut,
6366 "timeout",
6367 "activity timed out",
6368 ),
6369 _ => unreachable!("activity_outcome is called only for terminal activity events"),
6370 };
6371 let exception = payload
6372 .get("exception")
6373 .filter(|value| !value.is_null())
6374 .cloned();
6375 let failure_category = payload_string(payload, "failure_category");
6376 let timeout_kind = payload_string(payload, "timeout_kind");
6377 let reason = payload_string(payload, "reason").unwrap_or_else(|| match kind {
6378 ActivityFailureKind::Failed => failure_category
6379 .clone()
6380 .unwrap_or_else(|| fallback_reason.to_string()),
6381 ActivityFailureKind::Cancelled => fallback_reason.to_string(),
6382 ActivityFailureKind::TimedOut => timeout_kind
6383 .clone()
6384 .unwrap_or_else(|| fallback_reason.to_string()),
6385 });
6386 let message = payload_string(payload, "message")
6387 .or_else(|| {
6388 exception
6389 .as_ref()
6390 .and_then(|value| payload_string(value, "message"))
6391 })
6392 .unwrap_or_else(|| fallback_message.to_string());
6393
6394 Ok(Err(ActivityFailure {
6395 kind,
6396 reason,
6397 message,
6398 activity_execution_id: payload_string(payload, "activity_execution_id"),
6399 activity_attempt_id: payload_string(payload, "activity_attempt_id"),
6400 activity_type: payload_string(payload, "activity_type")
6401 .or_else(|| payload_string(payload, "activity_name"))
6402 .or(recorded_activity_type),
6403 activity_class: payload_string(payload, "activity_class"),
6404 attempt_number: payload.get("attempt_number").and_then(value_as_u64),
6405 failure_id: payload_string(payload, "failure_id"),
6406 failure_category,
6407 timeout_kind,
6408 non_retryable: payload
6409 .get("non_retryable")
6410 .and_then(Value::as_bool)
6411 .unwrap_or(false),
6412 exception_type: payload_string(payload, "exception_type").or_else(|| {
6413 exception
6414 .as_ref()
6415 .and_then(|value| payload_string(value, "type"))
6416 }),
6417 exception_class: payload_string(payload, "exception_class").or_else(|| {
6418 exception
6419 .as_ref()
6420 .and_then(|value| payload_string(value, "class"))
6421 }),
6422 code: payload
6423 .get("code")
6424 .filter(|value| !value.is_null())
6425 .cloned(),
6426 exception,
6427 }))
6428}
6429
6430type ChildWorkflowOutcome = std::result::Result<ChildWorkflowResult, ChildWorkflowFailure>;
6431
6432fn child_workflow_outcomes(
6433 events: &[HistoryEvent],
6434 fallback_codec: &str,
6435 parent: WorkflowIdentity,
6436) -> Result<Vec<ChildWorkflowOutcome>> {
6437 let mut outcomes = Vec::new();
6438
6439 for event in events {
6440 let kind = match event.event_type.as_str() {
6441 "ChildRunCompleted" => None,
6442 "ChildRunFailed" => Some((
6443 ChildWorkflowFailureKind::Failed,
6444 "child_workflow",
6445 "child workflow failed",
6446 )),
6447 "ChildRunCancelled" => Some((
6448 ChildWorkflowFailureKind::Cancelled,
6449 "cancelled",
6450 "child workflow was cancelled",
6451 )),
6452 "ChildRunTerminated" => Some((
6453 ChildWorkflowFailureKind::Terminated,
6454 "terminated",
6455 "child workflow was terminated",
6456 )),
6457 _ => continue,
6458 };
6459 let payload = &event.payload;
6460 let child_workflow_id = payload_string(payload, "child_workflow_instance_id");
6461 let child_workflow_run_id = payload_string(payload, "child_workflow_run_id");
6462 let child_workflow_type = payload_string(payload, "child_workflow_type");
6463
6464 if let Some((kind, reason, fallback_message)) = kind {
6465 let exception = payload
6466 .get("exception")
6467 .filter(|value| !value.is_null())
6468 .cloned();
6469 let message = payload_string(payload, "message")
6470 .or_else(|| {
6471 exception
6472 .as_ref()
6473 .and_then(|value| payload_string(value, "message"))
6474 })
6475 .unwrap_or_else(|| fallback_message.to_string());
6476 let exception_type = payload_string(payload, "exception_type").or_else(|| {
6477 exception
6478 .as_ref()
6479 .and_then(|value| payload_string(value, "type"))
6480 });
6481 let exception_class = payload_string(payload, "exception_class").or_else(|| {
6482 exception
6483 .as_ref()
6484 .and_then(|value| payload_string(value, "class"))
6485 });
6486 outcomes.push(Err(ChildWorkflowFailure {
6487 kind,
6488 reason: reason.to_string(),
6489 message,
6490 parent_workflow_id: parent.workflow_id.clone(),
6491 parent_workflow_run_id: parent.run_id.clone(),
6492 child_workflow_id,
6493 child_workflow_run_id,
6494 child_workflow_type,
6495 failure_id: payload_string(payload, "failure_id"),
6496 failure_category: payload_string(payload, "failure_category"),
6497 exception_type,
6498 exception_class,
6499 non_retryable: payload
6500 .get("non_retryable")
6501 .and_then(Value::as_bool)
6502 .unwrap_or(false),
6503 code: payload
6504 .get("code")
6505 .filter(|value| !value.is_null())
6506 .cloned(),
6507 exception,
6508 }));
6509 continue;
6510 }
6511
6512 let codec = payload
6513 .get("payload_codec")
6514 .and_then(Value::as_str)
6515 .unwrap_or(fallback_codec);
6516 let result = payload
6517 .get("result")
6518 .or_else(|| payload.get("output"))
6519 .unwrap_or(&Value::Null);
6520 outcomes.push(Ok(ChildWorkflowResult {
6521 parent: parent.clone(),
6522 child: WorkflowIdentity {
6523 workflow_id: child_workflow_id,
6524 run_id: child_workflow_run_id,
6525 },
6526 child_workflow_type,
6527 result: decode_wire_value(result, codec)?,
6528 }));
6529 }
6530
6531 Ok(outcomes)
6532}
6533
6534fn payload_string(payload: &Value, key: &str) -> Option<String> {
6535 payload
6536 .get(key)
6537 .and_then(Value::as_str)
6538 .filter(|value| !value.is_empty())
6539 .map(str::to_string)
6540}
6541
6542fn workflow_failure_command(error: &Error) -> Value {
6543 let (exception_type, exception_class, properties) = match error {
6544 Error::ActivityFailed(failure) => (
6545 match failure.kind {
6546 ActivityFailureKind::Failed => "ActivityFailed",
6547 ActivityFailureKind::Cancelled => "ActivityCancelled",
6548 ActivityFailureKind::TimedOut => "ActivityTimedOut",
6549 },
6550 "durable_workflow::ActivityFailure",
6551 json!({
6552 "reason": failure.reason,
6553 "activity_execution_id": failure.activity_execution_id,
6554 "activity_attempt_id": failure.activity_attempt_id,
6555 "activity_type": failure.activity_type,
6556 "activity_class": failure.activity_class,
6557 "attempt_number": failure.attempt_number,
6558 "failure_id": failure.failure_id,
6559 "failure_category": failure.failure_category,
6560 "timeout_kind": failure.timeout_kind,
6561 "activity_non_retryable": failure.non_retryable,
6562 "activity_exception_type": failure.exception_type,
6563 "activity_exception_class": failure.exception_class,
6564 "activity_code": failure.code,
6565 "activity_exception": failure.exception,
6566 }),
6567 ),
6568 Error::ChildWorkflowFailed(failure) => (
6569 match failure.kind {
6570 ChildWorkflowFailureKind::Failed => "ChildWorkflowFailed",
6571 ChildWorkflowFailureKind::Cancelled => "ChildWorkflowCancelled",
6572 ChildWorkflowFailureKind::Terminated => "ChildWorkflowTerminated",
6573 },
6574 "durable_workflow::ChildWorkflowFailure",
6575 json!({
6576 "reason": failure.reason,
6577 "parent_workflow_id": failure.parent_workflow_id,
6578 "parent_workflow_run_id": failure.parent_workflow_run_id,
6579 "child_workflow_id": failure.child_workflow_id,
6580 "child_workflow_run_id": failure.child_workflow_run_id,
6581 "child_workflow_type": failure.child_workflow_type,
6582 "failure_id": failure.failure_id,
6583 "failure_category": failure.failure_category,
6584 "child_exception_type": failure.exception_type,
6585 "child_exception_class": failure.exception_class,
6586 "child_non_retryable": failure.non_retryable,
6587 "child_code": failure.code,
6588 "child_exception": failure.exception,
6589 }),
6590 ),
6591 Error::NonDeterministicReplay(_) => (
6592 "NonDeterministicReplay",
6593 "durable_workflow::Error",
6594 Value::Null,
6595 ),
6596 _ => ("RustWorkflowError", "durable_workflow::Error", Value::Null),
6597 };
6598 let non_retryable = match error {
6599 Error::ActivityFailed(failure) => failure.non_retryable,
6600 Error::ChildWorkflowFailed(failure) => failure.non_retryable,
6601 Error::NonDeterministicReplay(_) => true,
6602 _ => false,
6603 };
6604
6605 json!({
6606 "type": "fail_workflow",
6607 "message": error.to_string(),
6608 "exception_type": exception_type,
6609 "exception_class": exception_class,
6610 "non_retryable": non_retryable,
6611 "exception": {
6612 "type": exception_type,
6613 "class": exception_class,
6614 "message": error.to_string(),
6615 "properties": properties,
6616 }
6617 })
6618}
6619
6620fn workflow_task_integrity_error(error: &Error) -> bool {
6621 matches!(
6622 error,
6623 Error::NonDeterministicReplay(_) | Error::Protocol(_) | Error::WorkflowStatePoisoned
6624 )
6625}
6626
6627fn signal_values(
6628 events: &[HistoryEvent],
6629 signal_name: &str,
6630 fallback_codec: &str,
6631 resume_signal: Option<&ResumeSignal>,
6632) -> Result<Vec<Vec<Value>>> {
6633 let mut signals = Vec::new();
6634
6635 for event in events {
6636 if event.event_type != "SignalApplied" && event.event_type != "SignalReceived" {
6637 continue;
6638 }
6639
6640 if event.payload.get("signal_name").and_then(Value::as_str) != Some(signal_name) {
6641 continue;
6642 }
6643
6644 let codec = event
6645 .payload
6646 .get("payload_codec")
6647 .and_then(Value::as_str)
6648 .unwrap_or(fallback_codec);
6649 let raw = event
6650 .payload
6651 .get("value")
6652 .or_else(|| event.payload.get("input"))
6653 .or_else(|| event.payload.get("arguments"));
6654 let decoded = match raw.filter(|value| !value.is_null()) {
6655 Some(value) => decode_wire_value(value, codec)?,
6656 None => resume_signal
6657 .filter(|signal| resume_signal_matches_event(signal, event, signal_name))
6658 .map(|signal| Value::Array(signal.arguments.clone()))
6659 .unwrap_or_else(|| Value::Array(Vec::new())),
6660 };
6661 let args = match normalize_arguments(decoded) {
6662 Value::Array(values) => values,
6663 _ => unreachable!("normalize_arguments always returns an array"),
6664 };
6665 signals.push(args);
6666 }
6667
6668 Ok(signals)
6669}
6670
6671fn hydrate_query_history_from_export(task: &mut QueryTask) -> Result<()> {
6672 let Some(export_events) = task
6673 .history_export
6674 .as_ref()
6675 .and_then(|export| export.get("history_events"))
6676 .and_then(Value::as_array)
6677 else {
6678 return Ok(());
6679 };
6680
6681 if export_events.len() > task.history_events.len() {
6682 task.history_events = serde_json::from_value(Value::Array(export_events.clone()))?;
6683 }
6684
6685 Ok(())
6686}
6687
6688fn enrich_query_history_from_export(task: &mut QueryTask) -> Result<()> {
6689 let Some(export) = task.history_export.as_ref() else {
6690 return Ok(());
6691 };
6692 let signals = export
6693 .get("signals")
6694 .and_then(Value::as_array)
6695 .cloned()
6696 .unwrap_or_default();
6697 let activities = export
6698 .get("activities")
6699 .and_then(Value::as_array)
6700 .cloned()
6701 .unwrap_or_default();
6702 let export_codec = export
6703 .get("payloads")
6704 .and_then(|payloads| payloads.get("codec"))
6705 .and_then(Value::as_str)
6706 .unwrap_or(&task.payload_codec)
6707 .to_string();
6708 let mut signal_name_offsets: HashMap<String, usize> = HashMap::new();
6709
6710 for event in &mut task.history_events {
6711 if event.event_type == "ActivityCompleted" {
6712 let sequence = event
6713 .payload
6714 .get("sequence")
6715 .or_else(|| event.payload.get("workflow_sequence"))
6716 .and_then(value_as_u64);
6717 let Some(activity) = sequence.and_then(|sequence| {
6718 activities.iter().find(|activity| {
6719 activity.get("sequence").and_then(value_as_u64) == Some(sequence)
6720 })
6721 }) else {
6722 continue;
6723 };
6724 let Some(payload) = event.payload.as_object_mut() else {
6725 continue;
6726 };
6727 if missing_payload(payload.get("result")) {
6728 if let Some(result) = activity
6729 .get("result")
6730 .filter(|value| !missing_payload(Some(value)))
6731 {
6732 payload.insert("result".to_string(), result.clone());
6733 }
6734 }
6735 for field in ["payload_codec", "activity_type"] {
6736 if payload
6737 .get(field)
6738 .and_then(Value::as_str)
6739 .unwrap_or_default()
6740 .is_empty()
6741 {
6742 if let Some(value) = activity.get(field) {
6743 payload.insert(field.to_string(), value.clone());
6744 }
6745 }
6746 }
6747 continue;
6748 }
6749
6750 if event.event_type != "SignalReceived" && event.event_type != "SignalApplied" {
6751 continue;
6752 }
6753 let signal_id = event.payload.get("signal_id").and_then(Value::as_str);
6754 let command_id = event
6755 .payload
6756 .get("workflow_command_id")
6757 .or_else(|| event.raw.get("workflow_command_id"))
6758 .and_then(Value::as_str);
6759 let signal_name = event
6760 .payload
6761 .get("signal_name")
6762 .and_then(Value::as_str)
6763 .unwrap_or_default()
6764 .to_string();
6765 let matched = signals
6766 .iter()
6767 .find(|signal| {
6768 signal_id.is_some() && signal.get("id").and_then(Value::as_str) == signal_id
6769 })
6770 .or_else(|| {
6771 signals.iter().find(|signal| {
6772 command_id.is_some()
6773 && signal.get("command_id").and_then(Value::as_str) == command_id
6774 })
6775 })
6776 .or_else(|| {
6777 let offset = signal_name_offsets.entry(signal_name.clone()).or_default();
6778 let signal = signals
6779 .iter()
6780 .filter(|signal| {
6781 signal.get("name").and_then(Value::as_str) == Some(signal_name.as_str())
6782 })
6783 .nth(*offset);
6784 if signal.is_some() {
6785 *offset += 1;
6786 }
6787 signal
6788 });
6789 let Some(signal) = matched else {
6790 continue;
6791 };
6792 let signal_codec = signal
6793 .get("payload_codec")
6794 .and_then(Value::as_str)
6795 .unwrap_or(&export_codec);
6796 let Some(payload) = event.payload.as_object_mut() else {
6797 continue;
6798 };
6799 if missing_payload(payload.get("arguments")) {
6800 if let Some(arguments) = signal
6801 .get("arguments")
6802 .filter(|value| !missing_payload(Some(value)))
6803 {
6804 let envelope = match arguments {
6805 Value::String(blob) => json!({"codec": signal_codec, "blob": blob}),
6806 other => other.clone(),
6807 };
6808 payload.insert("arguments".to_string(), envelope);
6809 }
6810 }
6811 if payload
6812 .get("payload_codec")
6813 .and_then(Value::as_str)
6814 .unwrap_or_default()
6815 .is_empty()
6816 {
6817 payload.insert("payload_codec".to_string(), json!(signal_codec));
6818 }
6819 }
6820
6821 Ok(())
6822}
6823
6824fn missing_payload(value: Option<&Value>) -> bool {
6825 match value {
6826 None | Some(Value::Null) => true,
6827 Some(Value::String(value)) => value.is_empty(),
6828 Some(_) => false,
6829 }
6830}
6831
6832fn query_signal_events(task: &QueryTask) -> Result<Vec<QuerySignal>> {
6833 let export_signals = task
6834 .history_export
6835 .as_ref()
6836 .and_then(|export| export.get("signals"))
6837 .and_then(Value::as_array)
6838 .cloned()
6839 .unwrap_or_default();
6840 let export_codec = task
6841 .history_export
6842 .as_ref()
6843 .and_then(|export| export.get("payloads"))
6844 .and_then(|payloads| payloads.get("codec"))
6845 .and_then(Value::as_str)
6846 .unwrap_or(&task.payload_codec);
6847 let mut name_offsets: HashMap<String, usize> = HashMap::new();
6848 let mut signals = Vec::new();
6849
6850 for event in &task.history_events {
6851 if event.event_type != "SignalApplied" && event.event_type != "SignalReceived" {
6852 continue;
6853 }
6854
6855 let name = event
6856 .payload
6857 .get("signal_name")
6858 .and_then(Value::as_str)
6859 .unwrap_or_default();
6860 if name.is_empty() {
6861 continue;
6862 }
6863 let signal_id = event.payload.get("signal_id").and_then(Value::as_str);
6864 let command_id = event
6865 .payload
6866 .get("workflow_command_id")
6867 .or_else(|| event.raw.get("workflow_command_id"))
6868 .and_then(Value::as_str);
6869 let matched_export = export_signals
6870 .iter()
6871 .find(|candidate| {
6872 signal_id.is_some() && candidate.get("id").and_then(Value::as_str) == signal_id
6873 })
6874 .or_else(|| {
6875 export_signals.iter().find(|candidate| {
6876 command_id.is_some()
6877 && candidate.get("command_id").and_then(Value::as_str) == command_id
6878 })
6879 })
6880 .or_else(|| {
6881 let offset = name_offsets.entry(name.to_string()).or_default();
6882 let candidate = export_signals
6883 .iter()
6884 .filter(|candidate| candidate.get("name").and_then(Value::as_str) == Some(name))
6885 .nth(*offset);
6886 if candidate.is_some() {
6887 *offset += 1;
6888 }
6889 candidate
6890 });
6891 let codec = event
6892 .payload
6893 .get("payload_codec")
6894 .and_then(Value::as_str)
6895 .or_else(|| {
6896 matched_export
6897 .and_then(|signal| signal.get("payload_codec"))
6898 .and_then(Value::as_str)
6899 })
6900 .unwrap_or(export_codec);
6901 let raw_arguments = event
6902 .payload
6903 .get("value")
6904 .or_else(|| event.payload.get("input"))
6905 .or_else(|| event.payload.get("arguments"))
6906 .filter(|value| !value.is_null())
6907 .or_else(|| matched_export.and_then(|signal| signal.get("arguments")));
6908 let arguments = decode_query_signal_arguments(raw_arguments, codec)?;
6909 let workflow_sequence = event
6910 .payload
6911 .get("workflow_sequence")
6912 .and_then(value_as_u64)
6913 .or_else(|| {
6914 matched_export
6915 .and_then(|signal| signal.get("workflow_sequence"))
6916 .and_then(value_as_u64)
6917 });
6918
6919 signals.push(QuerySignal {
6920 id: signal_id.map(str::to_string).or_else(|| {
6921 matched_export
6922 .and_then(|signal| signal.get("id"))
6923 .and_then(Value::as_str)
6924 .map(str::to_string)
6925 }),
6926 name: name.to_string(),
6927 arguments,
6928 workflow_sequence,
6929 });
6930 }
6931
6932 if signals.is_empty() {
6933 for signal in export_signals {
6934 if signal.get("status").and_then(Value::as_str) == Some("rejected") {
6935 continue;
6936 }
6937 let Some(name) = signal.get("name").and_then(Value::as_str) else {
6938 continue;
6939 };
6940 let codec = signal
6941 .get("payload_codec")
6942 .and_then(Value::as_str)
6943 .unwrap_or(export_codec);
6944 let arguments = decode_query_signal_arguments(signal.get("arguments"), codec)?;
6945 signals.push(QuerySignal {
6946 id: signal.get("id").and_then(Value::as_str).map(str::to_string),
6947 name: name.to_string(),
6948 arguments,
6949 workflow_sequence: signal.get("workflow_sequence").and_then(value_as_u64),
6950 });
6951 }
6952 signals.sort_by_key(|signal| signal.workflow_sequence.unwrap_or(u64::MAX));
6953 }
6954
6955 Ok(signals)
6956}
6957
6958fn decode_query_signal_arguments(raw: Option<&Value>, codec: &str) -> Result<Vec<Value>> {
6959 let decoded = match raw.filter(|value| !value.is_null()) {
6960 Some(value) => decode_wire_value(value, codec)?,
6961 None => Value::Array(Vec::new()),
6962 };
6963 let Value::Array(arguments) = normalize_arguments(decoded) else {
6964 unreachable!("normalize_arguments always returns an array");
6965 };
6966 Ok(arguments)
6967}
6968
6969fn value_as_u64(value: &Value) -> Option<u64> {
6970 value
6971 .as_u64()
6972 .or_else(|| value.as_str().and_then(|value| value.parse().ok()))
6973}
6974
6975fn resume_signal_matches_event(
6976 resume_signal: &ResumeSignal,
6977 event: &HistoryEvent,
6978 signal_name: &str,
6979) -> bool {
6980 if resume_signal.signal_name != signal_name {
6981 return false;
6982 }
6983
6984 match (
6985 resume_signal.signal_id.as_deref(),
6986 event.payload.get("signal_id").and_then(Value::as_str),
6987 ) {
6988 (Some(resume_id), Some(event_id)) => resume_id == event_id,
6989 _ => true,
6990 }
6991}
6992
6993#[cfg(test)]
6994mod tests {
6995 use super::*;
6996 use std::{
6997 io::{Read, Write},
6998 net::{SocketAddr, TcpListener, TcpStream},
6999 sync::atomic::AtomicUsize,
7000 thread,
7001 };
7002
7003 #[derive(Clone, Debug, Default, PartialEq)]
7004 struct ReplayCounterState {
7005 loaded: Option<String>,
7006 count: i64,
7007 finished: bool,
7008 }
7009
7010 fn replay_counter_worker() -> Worker {
7011 let client = Client::new("http://127.0.0.1:8080").expect("client");
7012 let mut worker = Worker::new(client, "rust-workers");
7013 worker.register_replayed_workflow(
7014 "replay-counter",
7015 ReplayCounterState::default,
7016 |ctx, _input, state| async move {
7017 let loaded = ctx.activity("load-counter", json!([])).await?;
7018 state.update(|current| {
7019 current.loaded = loaded.as_str().map(str::to_string);
7020 })?;
7021 for _ in 0..2 {
7022 let signal = ctx.wait_signal("increment").await?;
7023 let amount = signal.first().and_then(Value::as_i64).unwrap_or_default();
7024 state.update(|current| current.count += amount)?;
7025 }
7026 state.update(|current| current.finished = true)?;
7027 state.read(|current| Ok(json!(current.count)))?
7028 },
7029 );
7030 worker.register_replayed_query::<ReplayCounterState, _, _>(
7031 "replay-counter",
7032 "current",
7033 |_ctx, state, _args| async move {
7034 Ok(json!({
7035 "loaded": state.loaded,
7036 "count": state.count,
7037 "finished": state.finished,
7038 }))
7039 },
7040 );
7041 worker.register_replayed_query::<ReplayCounterState, _, _>(
7042 "replay-counter",
7043 "detached-mutation",
7044 |_ctx, state, _args| async move {
7045 let mut detached = (*state).clone();
7046 detached.count = 999;
7047 Ok(json!(detached.count))
7048 },
7049 );
7050 worker.register_replayed_query::<ReplayCounterState, _, _>(
7051 "replay-counter",
7052 "failed-mutation",
7053 |_ctx, state, _args| async move {
7054 let mut detached = (*state).clone();
7055 detached.count = 999;
7056 Err(Error::WorkerLoop("query refused".to_string()))
7057 },
7058 );
7059 worker
7060 }
7061
7062 fn replay_counter_query(
7063 query_name: &str,
7064 history_events: Value,
7065 run_status: &str,
7066 ) -> QueryTask {
7067 serde_json::from_value(json!({
7068 "query_task_id": format!("query-{query_name}"),
7069 "workflow_type": "replay-counter",
7070 "query_name": query_name,
7071 "payload_codec": "json",
7072 "workflow_arguments": {"codec": "json", "blob": "[]"},
7073 "query_arguments": {"codec": "json", "blob": "[]"},
7074 "history_events": history_events,
7075 "run_status": run_status,
7076 }))
7077 .expect("query task")
7078 }
7079
7080 fn workflow_context(history: Vec<HistoryEvent>) -> WorkflowContext {
7081 workflow_context_with_codec(history, JSON_CODEC)
7082 }
7083
7084 fn workflow_context_with_codec(
7085 history: Vec<HistoryEvent>,
7086 payload_codec: &str,
7087 ) -> WorkflowContext {
7088 WorkflowContext {
7089 state: Arc::new(Mutex::new(
7090 WorkflowState::new_with_identity(
7091 history,
7092 None,
7093 None,
7094 "rust-workers".to_string(),
7095 payload_codec.to_string(),
7096 None,
7097 )
7098 .expect("valid workflow history"),
7099 )),
7100 }
7101 }
7102
7103 fn history_event(event_type: &str, payload: Value) -> HistoryEvent {
7104 HistoryEvent {
7105 event_type: event_type.to_string(),
7106 payload,
7107 raw: HashMap::new(),
7108 }
7109 }
7110
7111 fn workflow_task(
7112 workflow_type: &str,
7113 history_events: Vec<HistoryEvent>,
7114 payload_codec: &str,
7115 ) -> WorkflowTask {
7116 WorkflowTask {
7117 task_id: format!("wft-{workflow_type}"),
7118 workflow_id: Some(format!("wf-{workflow_type}")),
7119 run_id: Some(format!("run-{workflow_type}")),
7120 workflow_type: workflow_type.to_string(),
7121 payload_codec: payload_codec.to_string(),
7122 arguments: Some(
7123 encode_value_envelope(&json!([]), payload_codec).expect("workflow arguments"),
7124 ),
7125 total_history_events: Some(history_events.len() as u64),
7126 history_size_bytes: None,
7127 continue_as_new_recommended: None,
7128 history_budget_pressure: None,
7129 history_events,
7130 next_history_page_token: None,
7131 workflow_task_attempt: 1,
7132 workflow_signal_id: None,
7133 signal_name: None,
7134 signal_arguments: None,
7135 lease_owner: Some("rust-worker".to_string()),
7136 }
7137 }
7138
7139 #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
7140 struct SideEffectProbe {
7141 request_id: String,
7142 attempt: u32,
7143 }
7144
7145 #[test]
7146 fn typed_side_effect_runs_callback_once_and_replay_skips_it() {
7147 let calls = AtomicUsize::new(0);
7148 let ctx = workflow_context(Vec::new());
7149 let value = ctx
7150 .side_effect(|| {
7151 calls.fetch_add(1, Ordering::SeqCst);
7152 SideEffectProbe {
7153 request_id: "request-42".to_string(),
7154 attempt: 3,
7155 }
7156 })
7157 .expect("first side effect");
7158 assert_eq!(value.attempt, 3);
7159 assert_eq!(calls.load(Ordering::SeqCst), 1);
7160 let commands = ctx.take_commands().expect("commands");
7161 assert_eq!(commands.len(), 1);
7162 assert_eq!(commands[0]["type"], "record_side_effect");
7163 assert_eq!(
7164 decode_wire_value(&commands[0]["result"], JSON_CODEC).expect("JSON result"),
7165 serde_json::to_value(&value).expect("value")
7166 );
7167
7168 let replay = workflow_context(vec![history_event(
7169 "SideEffectRecorded",
7170 json!({"sequence": 1, "result": commands[0]["result"].clone()}),
7171 )]);
7172 let replayed: SideEffectProbe = replay
7173 .side_effect(|| {
7174 calls.fetch_add(1, Ordering::SeqCst);
7175 panic!("committed side-effect callbacks must not run during replay")
7176 })
7177 .expect("replayed side effect");
7178 assert_eq!(replayed, value);
7179 assert_eq!(calls.load(Ordering::SeqCst), 1);
7180 assert!(replay.take_commands().expect("commands").is_empty());
7181 replay.ensure_history_consumed().expect("history consumed");
7182 }
7183
7184 #[test]
7185 fn side_effect_uses_avro_envelope_and_uuid_is_replay_stable() {
7186 let ctx = workflow_context_with_codec(Vec::new(), DEFAULT_CODEC);
7187 let value = ctx
7188 .side_effect(|| SideEffectProbe {
7189 request_id: "avro-request".to_string(),
7190 attempt: 1,
7191 })
7192 .expect("Avro side effect");
7193 let uuid = ctx.uuid_v4().expect("deterministic UUID");
7194 let commands = ctx.take_commands().expect("commands");
7195 assert_eq!(commands.len(), 2);
7196 assert_eq!(commands[0]["result"]["codec"], DEFAULT_CODEC);
7197 assert_eq!(commands[1]["result"]["codec"], DEFAULT_CODEC);
7198 assert_eq!(
7199 decode_wire_value(&commands[0]["result"], DEFAULT_CODEC).expect("Avro result"),
7200 serde_json::to_value(&value).expect("value")
7201 );
7202
7203 let replay = workflow_context_with_codec(
7204 vec![
7205 history_event(
7206 "SideEffectRecorded",
7207 json!({"sequence": 1, "result": commands[0]["result"].clone()}),
7208 ),
7209 history_event(
7210 "SideEffectRecorded",
7211 json!({"sequence": 2, "result": commands[1]["result"].clone()}),
7212 ),
7213 ],
7214 DEFAULT_CODEC,
7215 );
7216 let replayed: SideEffectProbe = replay
7217 .side_effect(|| panic!("Avro callback must not run"))
7218 .expect("replayed Avro value");
7219 let replayed_uuid = replay.uuid_v4().expect("replayed UUID");
7220 assert_eq!(replayed, value);
7221 assert_eq!(replayed_uuid, uuid);
7222 assert!(replay.take_commands().expect("commands").is_empty());
7223 }
7224
7225 #[test]
7226 fn ordered_side_effects_share_the_durable_command_stream() {
7227 let first = encode_value_envelope(&json!("first"), JSON_CODEC).expect("first");
7228 let second = encode_value_envelope(&json!(29), JSON_CODEC).expect("second");
7229 let ctx = workflow_context(vec![
7230 history_event(
7231 "SideEffectRecorded",
7232 json!({"sequence": 1, "result": first}),
7233 ),
7234 history_event(
7235 "SideEffectRecorded",
7236 json!({"sequence": 2, "result": second}),
7237 ),
7238 ]);
7239 let first: String = ctx
7240 .side_effect(|| panic!("first callback must not run"))
7241 .expect("first replay");
7242 let second: i32 = ctx
7243 .side_effect(|| panic!("second callback must not run"))
7244 .expect("second replay");
7245 assert_eq!(first, "first");
7246 assert_eq!(second, 29);
7247 ctx.ensure_history_consumed().expect("ordered history");
7248
7249 let reordered = workflow_context(vec![history_event(
7250 "VersionMarkerRecorded",
7251 json!({
7252 "sequence": 1,
7253 "change_id": "before-side-effect",
7254 "version": 1,
7255 "min_supported": 1,
7256 "max_supported": 1,
7257 }),
7258 )]);
7259 let error = reordered
7260 .side_effect(|| "new".to_string())
7261 .expect_err("command reordering must fail");
7262 assert!(matches!(
7263 error,
7264 Error::NonDeterministicReplay(ReplayFailure { ref reason, .. })
7265 if reason == "recorded_command_mismatch"
7266 ));
7267 }
7268
7269 #[test]
7270 fn version_markers_replay_across_upgrades_and_do_not_duplicate() {
7271 let ctx = workflow_context(Vec::new());
7272 assert_eq!(ctx.get_version("checkout-v2", 1, 2).expect("version"), 2);
7273 assert_eq!(ctx.get_version("checkout-v2", 1, 3).expect("cached"), 2);
7274 assert!(ctx.patched("new-search").expect("patch"));
7275 ctx.deprecate_patch("new-search").expect("deprecate patch");
7276 let commands = ctx.take_commands().expect("commands");
7277 assert_eq!(commands.len(), 2);
7278 assert_eq!(commands[0]["type"], "record_version_marker");
7279 assert_eq!(commands[0]["version"], 2);
7280 assert_eq!(commands[1]["change_id"], "new-search");
7281
7282 let replay = workflow_context(vec![history_event(
7283 "VersionMarkerRecorded",
7284 json!({
7285 "sequence": 1,
7286 "change_id": "checkout-v2",
7287 "version": 2,
7288 "min_supported": 1,
7289 "max_supported": 2,
7290 }),
7291 )]);
7292 assert_eq!(replay.get_version("checkout-v2", 1, 4).expect("upgrade"), 2);
7293 assert_eq!(replay.get_version("checkout-v2", 2, 5).expect("repeat"), 2);
7294 assert!(replay.take_commands().expect("commands").is_empty());
7295 replay.ensure_history_consumed().expect("history consumed");
7296 }
7297
7298 #[test]
7299 fn version_markers_reject_incompatible_or_malformed_history() {
7300 let incompatible = workflow_context(vec![history_event(
7301 "VersionMarkerRecorded",
7302 json!({
7303 "sequence": 1,
7304 "change_id": "checkout-v2",
7305 "version": 1,
7306 "min_supported": 1,
7307 "max_supported": 2,
7308 }),
7309 )]);
7310 let error = incompatible
7311 .get_version("checkout-v2", 2, 3)
7312 .expect_err("old version is unsupported");
7313 assert!(matches!(
7314 error,
7315 Error::NonDeterministicReplay(ReplayFailure { ref reason, .. })
7316 if reason == "version_marker_incompatible_range"
7317 ));
7318
7319 for (history, reason) in [
7320 (
7321 vec![history_event("SideEffectRecorded", json!({"sequence": 1}))],
7322 "side_effect_result_missing",
7323 ),
7324 (
7325 vec![history_event(
7326 "SideEffectRecorded",
7327 json!({
7328 "sequence": 1,
7329 "result": {"codec": "avro", "blob": "not-base64"},
7330 }),
7331 )],
7332 "side_effect_payload_incompatible",
7333 ),
7334 (
7335 vec![history_event(
7336 "SideEffectRecorded",
7337 json!({"sequence": 1, "result": {"unwrapped": true}}),
7338 )],
7339 "side_effect_payload_malformed",
7340 ),
7341 (
7342 vec![history_event(
7343 "VersionMarkerRecorded",
7344 json!({
7345 "sequence": 1,
7346 "change_id": "change",
7347 "version": 1,
7348 "min_supported": 2,
7349 "max_supported": 1,
7350 }),
7351 )],
7352 "version_marker_history_range_invalid",
7353 ),
7354 ] {
7355 let error = WorkflowState::new(
7356 history,
7357 "rust-workers".to_string(),
7358 JSON_CODEC.to_string(),
7359 None,
7360 )
7361 .expect_err("malformed history must fail");
7362 assert!(matches!(
7363 error,
7364 Error::NonDeterministicReplay(ReplayFailure { reason: actual, .. })
7365 if actual == reason
7366 ));
7367 }
7368 }
7369
7370 #[test]
7371 fn duplicate_side_effects_and_version_markers_are_rejected() {
7372 let duplicate_side_effect = WorkflowState::new(
7373 vec![
7374 history_event(
7375 "SideEffectRecorded",
7376 json!({"sequence": 1, "result": {"codec": "json", "blob": "1"}}),
7377 ),
7378 history_event(
7379 "SideEffectRecorded",
7380 json!({"sequence": 1, "result": {"codec": "json", "blob": "2"}}),
7381 ),
7382 ],
7383 "rust-workers".to_string(),
7384 JSON_CODEC.to_string(),
7385 None,
7386 )
7387 .expect_err("duplicate side effect");
7388 assert!(matches!(
7389 duplicate_side_effect,
7390 Error::NonDeterministicReplay(ReplayFailure { ref reason, .. })
7391 if reason == "duplicate_side_effect_record"
7392 ));
7393
7394 let marker = |sequence| {
7395 history_event(
7396 "VersionMarkerRecorded",
7397 json!({
7398 "sequence": sequence,
7399 "change_id": "same-change",
7400 "version": 1,
7401 "min_supported": 1,
7402 "max_supported": 1,
7403 }),
7404 )
7405 };
7406 let duplicate_marker = WorkflowState::new(
7407 vec![marker(1), marker(2)],
7408 "rust-workers".to_string(),
7409 JSON_CODEC.to_string(),
7410 None,
7411 )
7412 .expect_err("duplicate marker");
7413 assert!(matches!(
7414 duplicate_marker,
7415 Error::NonDeterministicReplay(ReplayFailure { ref reason, .. })
7416 if reason == "duplicate_version_marker"
7417 ));
7418 }
7419
7420 #[test]
7421 fn cold_worker_replay_does_not_repeat_committed_side_effects_or_markers() {
7422 fn worker(calls: Arc<AtomicUsize>) -> Worker {
7423 let client = Client::new("http://127.0.0.1:8080").expect("client");
7424 let mut worker = Worker::new(client, "rust-workers");
7425 worker.register_workflow("rust.side-effect-version", move |ctx, _input| {
7426 let calls = Arc::clone(&calls);
7427 async move {
7428 let captured = ctx.side_effect(|| {
7429 calls.fetch_add(1, Ordering::SeqCst);
7430 "captured-once".to_string()
7431 })?;
7432 let version = ctx.get_version("cold-restart", 1, 2)?;
7433 Ok(json!({"captured": captured, "version": version}))
7434 }
7435 });
7436 worker
7437 }
7438
7439 fn task(history_events: Vec<HistoryEvent>) -> WorkflowTask {
7440 WorkflowTask {
7441 task_id: "wft-side-effect-version".to_string(),
7442 workflow_id: Some("wf-side-effect-version".to_string()),
7443 run_id: Some("run-side-effect-version".to_string()),
7444 workflow_type: "rust.side-effect-version".to_string(),
7445 payload_codec: JSON_CODEC.to_string(),
7446 arguments: Some(encode_value_envelope(&json!([]), JSON_CODEC).expect("arguments")),
7447 history_events,
7448 total_history_events: None,
7449 history_size_bytes: None,
7450 continue_as_new_recommended: None,
7451 history_budget_pressure: None,
7452 next_history_page_token: None,
7453 workflow_task_attempt: 1,
7454 workflow_signal_id: None,
7455 signal_name: None,
7456 signal_arguments: None,
7457 lease_owner: Some("rust-worker".to_string()),
7458 }
7459 }
7460
7461 let calls = Arc::new(AtomicUsize::new(0));
7462 let initial = worker(Arc::clone(&calls))
7463 .execute_workflow_task(task(Vec::new()))
7464 .expect("initial execution");
7465 assert_eq!(
7466 initial
7467 .iter()
7468 .map(|command| &command["type"])
7469 .collect::<Vec<_>>(),
7470 vec![
7471 "record_side_effect",
7472 "record_version_marker",
7473 "complete_workflow"
7474 ]
7475 );
7476 assert_eq!(calls.load(Ordering::SeqCst), 1);
7477
7478 let restarted = worker(Arc::clone(&calls));
7479 let replayed = restarted
7480 .execute_workflow_task(task(vec![
7481 history_event(
7482 "SideEffectRecorded",
7483 json!({"sequence": 1, "result": initial[0]["result"].clone()}),
7484 ),
7485 history_event(
7486 "VersionMarkerRecorded",
7487 json!({
7488 "sequence": 2,
7489 "change_id": "cold-restart",
7490 "version": 2,
7491 "min_supported": 1,
7492 "max_supported": 2,
7493 }),
7494 ),
7495 ]))
7496 .expect("cold replay");
7497 assert_eq!(replayed.len(), 1);
7498 assert_eq!(replayed[0]["type"], "complete_workflow");
7499 assert_eq!(calls.load(Ordering::SeqCst), 1);
7500 }
7501
7502 #[test]
7503 fn side_effect_replay_rejects_changed_rust_value_type() {
7504 let result = encode_value_envelope(&json!({"value": 42}), JSON_CODEC).expect("result");
7505 let ctx = workflow_context(vec![history_event(
7506 "SideEffectRecorded",
7507 json!({"sequence": 1, "result": result}),
7508 )]);
7509 let error = ctx
7510 .side_effect::<Vec<String>, _>(|| panic!("callback must not run"))
7511 .expect_err("changed type must fail replay");
7512 assert!(matches!(
7513 error,
7514 Error::NonDeterministicReplay(ReplayFailure { ref reason, .. })
7515 if reason == "side_effect_type_mismatch"
7516 ));
7517 }
7518
7519 fn completed_retry_activity_history() -> Vec<HistoryEvent> {
7520 vec![
7521 history_event(
7522 "ActivityScheduled",
7523 json!({
7524 "sequence": 1,
7525 "activity_type": "flaky",
7526 "activity_execution_id": "act-1",
7527 "activity": {
7528 "id": "act-1",
7529 "sequence": 1,
7530 "type": "flaky",
7531 "queue": "critical-activities",
7532 "execution_mode": null,
7533 "retry_policy": {
7534 "snapshot_version": 1,
7535 "max_attempts": 3,
7536 "backoff_seconds": [2, 4],
7537 "start_to_close_timeout": 30,
7538 "schedule_to_start_timeout": 5,
7539 "schedule_to_close_timeout": 90,
7540 "heartbeat_timeout": 10,
7541 "non_retryable_error_types": ["PermanentError"]
7542 }
7543 }
7544 }),
7545 ),
7546 history_event(
7547 "ActivityStarted",
7548 json!({
7549 "sequence": 1,
7550 "activity_type": "flaky",
7551 "activity_execution_id": "act-1",
7552 "activity_attempt_id": "attempt-1",
7553 "attempt_number": 1
7554 }),
7555 ),
7556 history_event(
7557 "ActivityRetryScheduled",
7558 json!({
7559 "sequence": 1,
7560 "activity_type": "flaky",
7561 "activity_execution_id": "act-1",
7562 "activity_attempt_id": "attempt-1",
7563 "attempt_number": 1,
7564 "retry_after_attempt": 1,
7565 "retry_backoff_seconds": 2,
7566 "failure_category": "activity",
7567 "exception_type": "TransientError"
7568 }),
7569 ),
7570 history_event(
7571 "ActivityStarted",
7572 json!({
7573 "sequence": 1,
7574 "activity_type": "flaky",
7575 "activity_execution_id": "act-1",
7576 "activity_attempt_id": "attempt-2",
7577 "attempt_number": 2
7578 }),
7579 ),
7580 history_event(
7581 "ActivityCompleted",
7582 json!({
7583 "sequence": 1,
7584 "activity_type": "flaky",
7585 "activity_execution_id": "act-1",
7586 "activity_attempt_id": "attempt-2",
7587 "attempt_number": 2,
7588 "payload_codec": "json",
7589 "result": {"codec": "json", "blob": "{\"status\":\"recovered\"}"}
7590 }),
7591 ),
7592 ]
7593 }
7594
7595 fn retry_activity_options() -> ActivityOptions {
7596 ActivityOptions::new()
7597 .task_queue("critical-activities")
7598 .retry_policy(
7599 ActivityRetryPolicy::new(3)
7600 .backoff_intervals([Duration::from_secs(2), Duration::from_secs(4)])
7601 .non_retryable_error_type("PermanentError"),
7602 )
7603 .start_to_close_timeout(Duration::from_secs(30))
7604 .schedule_to_start_timeout(Duration::from_secs(5))
7605 .schedule_to_close_timeout(Duration::from_secs(90))
7606 .heartbeat_timeout(Duration::from_secs(10))
7607 }
7608
7609 #[test]
7610 fn avro_generic_wrapper_round_trips_json_values() {
7611 let value = json!({"greeting": "hello", "count": 3, "ok": true});
7612 let envelope = PayloadEnvelope::avro(&value).expect("encode");
7613 assert_eq!(envelope.codec, DEFAULT_CODEC);
7614 assert_eq!(decode_payload::<Value>(&envelope).expect("decode"), value);
7615 }
7616
7617 #[test]
7618 fn json_codec_remains_plain_json() {
7619 let value = json!({"greeting": "hello", "count": 3, "ok": true});
7620 let envelope = PayloadEnvelope::json(&value).expect("encode");
7621
7622 assert_eq!(envelope.codec, JSON_CODEC);
7623 assert_eq!(envelope.blob, serde_json::to_string(&value).expect("json"));
7624 assert_eq!(decode_payload::<Value>(&envelope).expect("decode"), value);
7625 }
7626
7627 #[test]
7628 fn typed_avro_payload_without_schema_context_keeps_diagnostic() {
7629 let envelope = PayloadEnvelope {
7630 codec: DEFAULT_CODEC.to_string(),
7631 blob: BASE64.encode([0x01]),
7632 };
7633
7634 let error = decode_payload::<Value>(&envelope).expect_err("typed payload must fail");
7635 assert_eq!(
7636 error.to_string(),
7637 "codec error: typed avro payloads require a schema context; v1 supports the generic wrapper"
7638 );
7639 }
7640
7641 #[test]
7642 fn workflow_context_schedules_activity_until_completion_is_in_history() {
7643 let ctx = WorkflowContext {
7644 state: Arc::new(Mutex::new(
7645 WorkflowState::new_with_identity(
7646 Vec::new(),
7647 Some("wf-parent".to_string()),
7648 Some("run-parent".to_string()),
7649 "rust-workers".to_string(),
7650 DEFAULT_CODEC.to_string(),
7651 None,
7652 )
7653 .expect("workflow state"),
7654 )),
7655 };
7656
7657 let mut call = Box::pin(ctx.activity("hello.activity", json!(["Ada"])));
7658 let mut task_context = TaskContext::from_waker(noop_waker_ref());
7659 assert!(matches!(
7660 call.as_mut().poll(&mut task_context),
7661 Poll::Pending
7662 ));
7663
7664 let commands = ctx.take_commands().expect("commands");
7665 assert_eq!(commands[0]["type"], "schedule_activity");
7666 assert_eq!(commands[0]["activity_type"], "hello.activity");
7667 }
7668
7669 #[test]
7670 fn activity_options_encode_retry_policy_queue_and_every_timeout() {
7671 let ctx = workflow_context(Vec::new());
7672 let options = ActivityOptions::new()
7673 .task_queue("payments")
7674 .retry_policy(
7675 ActivityRetryPolicy::new(4)
7676 .exponential_backoff(Duration::from_secs(1), 3, Some(Duration::from_secs(10)))
7677 .non_retryable_error_type("ValidationError"),
7678 )
7679 .start_to_close_timeout(Duration::from_secs(120))
7680 .schedule_to_start_timeout(Duration::from_secs(10))
7681 .schedule_to_close_timeout(Duration::from_secs(300))
7682 .heartbeat_timeout(Duration::from_secs(15));
7683 let mut call = Box::pin(ctx.activity_with_options(
7684 "charge-card",
7685 options,
7686 json!([{"order_id": "o-1"}]),
7687 ));
7688 let mut task_context = TaskContext::from_waker(noop_waker_ref());
7689
7690 assert!(matches!(
7691 call.as_mut().poll(&mut task_context),
7692 Poll::Pending
7693 ));
7694 assert!(matches!(
7695 call.as_mut().poll(&mut task_context),
7696 Poll::Pending
7697 ));
7698
7699 let commands = ctx.take_commands().expect("activity command");
7700 assert_eq!(commands.len(), 1, "one future emits one logical schedule");
7701 assert_eq!(commands[0]["queue"], "payments");
7702 assert_eq!(
7703 commands[0]["retry_policy"],
7704 json!({
7705 "max_attempts": 4,
7706 "backoff_seconds": [1, 3, 9],
7707 "non_retryable_error_types": ["ValidationError"],
7708 })
7709 );
7710 assert_eq!(commands[0]["start_to_close_timeout"], 120);
7711 assert_eq!(commands[0]["schedule_to_start_timeout"], 10);
7712 assert_eq!(commands[0]["schedule_to_close_timeout"], 300);
7713 assert_eq!(commands[0]["heartbeat_timeout"], 15);
7714 }
7715
7716 #[test]
7717 fn activity_options_encode_explicit_and_rounded_backoff_intervals() {
7718 let ctx = workflow_context(Vec::new());
7719 let options = ActivityOptions::new().retry_policy(
7720 ActivityRetryPolicy::new(3)
7721 .backoff_intervals([Duration::from_millis(1), Duration::from_millis(1_001)]),
7722 );
7723 let mut call = Box::pin(ctx.activity_with_options("work", options, json!([])));
7724 let mut task_context = TaskContext::from_waker(noop_waker_ref());
7725
7726 assert!(matches!(
7727 call.as_mut().poll(&mut task_context),
7728 Poll::Pending
7729 ));
7730 assert_eq!(
7731 ctx.take_commands().expect("command")[0]["retry_policy"]["backoff_seconds"],
7732 json!([1, 2])
7733 );
7734 }
7735
7736 #[test]
7737 fn invalid_activity_options_return_typed_errors_before_emitting_commands() {
7738 let cases = [
7739 (
7740 ActivityOptions::new().task_queue(" "),
7741 ActivityOptionsErrorKind::EmptyTaskQueue,
7742 ),
7743 (
7744 ActivityOptions::new().retry_policy(ActivityRetryPolicy::default()),
7745 ActivityOptionsErrorKind::EmptyRetryPolicy,
7746 ),
7747 (
7748 ActivityOptions::new().retry_policy(ActivityRetryPolicy::new(0)),
7749 ActivityOptionsErrorKind::InvalidMaxAttempts,
7750 ),
7751 (
7752 ActivityOptions::new().retry_policy(ActivityRetryPolicy {
7753 max_attempts: None,
7754 backoff: Some(ActivityBackoff::Explicit(vec![Duration::from_secs(1)])),
7755 non_retryable_error_types: Vec::new(),
7756 }),
7757 ActivityOptionsErrorKind::BackoffWithoutRetryBudget,
7758 ),
7759 (
7760 ActivityOptions::new().retry_policy(
7761 ActivityRetryPolicy::new(2)
7762 .backoff_intervals([Duration::from_secs(1), Duration::from_secs(2)]),
7763 ),
7764 ActivityOptionsErrorKind::TooManyBackoffIntervals,
7765 ),
7766 (
7767 ActivityOptions::new().retry_policy(
7768 ActivityRetryPolicy::new(2).exponential_backoff(
7769 Duration::from_secs(1),
7770 0,
7771 None,
7772 ),
7773 ),
7774 ActivityOptionsErrorKind::InvalidBackoffCoefficient,
7775 ),
7776 (
7777 ActivityOptions::new()
7778 .retry_policy(ActivityRetryPolicy::new(2).non_retryable_error_type(" ")),
7779 ActivityOptionsErrorKind::EmptyNonRetryableErrorType,
7780 ),
7781 (
7782 ActivityOptions::new().retry_policy(
7783 ActivityRetryPolicy::new(10_002).exponential_backoff(
7784 Duration::from_secs(1),
7785 1,
7786 None,
7787 ),
7788 ),
7789 ActivityOptionsErrorKind::BackoffGenerationTooLarge,
7790 ),
7791 (
7792 ActivityOptions::new().retry_policy(
7793 ActivityRetryPolicy::new(2)
7794 .backoff_intervals([Duration::from_secs(i64::MAX as u64 + 1)]),
7795 ),
7796 ActivityOptionsErrorKind::BackoffOverflow,
7797 ),
7798 ];
7799
7800 for (options, expected_kind) in cases {
7801 let ctx = workflow_context(Vec::new());
7802 let mut call = Box::pin(ctx.activity_with_options("work", options, json!([])));
7803 let mut task_context = TaskContext::from_waker(noop_waker_ref());
7804 let Poll::Ready(Err(Error::InvalidActivityOptions(error))) =
7805 call.as_mut().poll(&mut task_context)
7806 else {
7807 panic!("expected typed activity validation error");
7808 };
7809 assert_eq!(error.kind, expected_kind);
7810 assert!(ctx.take_commands().expect("commands").is_empty());
7811 }
7812 }
7813
7814 #[test]
7815 fn activity_options_validate_positive_and_ordered_timeouts() {
7816 let zero_timeout_cases = [
7817 ActivityOptions::new().start_to_close_timeout(Duration::ZERO),
7818 ActivityOptions::new().schedule_to_start_timeout(Duration::ZERO),
7819 ActivityOptions::new().schedule_to_close_timeout(Duration::ZERO),
7820 ActivityOptions::new().heartbeat_timeout(Duration::ZERO),
7821 ];
7822 for options in zero_timeout_cases {
7823 assert_eq!(
7824 options.validate().expect_err("zero timeout").kind,
7825 ActivityOptionsErrorKind::TimeoutNotPositive
7826 );
7827 }
7828
7829 let ordering_cases = [
7830 ActivityOptions::new()
7831 .heartbeat_timeout(Duration::from_secs(11))
7832 .start_to_close_timeout(Duration::from_secs(10)),
7833 ActivityOptions::new()
7834 .start_to_close_timeout(Duration::from_secs(31))
7835 .schedule_to_close_timeout(Duration::from_secs(30)),
7836 ActivityOptions::new()
7837 .schedule_to_start_timeout(Duration::from_secs(31))
7838 .schedule_to_close_timeout(Duration::from_secs(30)),
7839 ];
7840 for options in ordering_cases {
7841 assert_eq!(
7842 options.validate().expect_err("timeout order").kind,
7843 ActivityOptionsErrorKind::TimeoutOrder
7844 );
7845 }
7846
7847 assert_eq!(
7848 ActivityOptions::new()
7849 .start_to_close_timeout(Duration::from_secs(i64::MAX as u64 + 1))
7850 .validate()
7851 .expect_err("protocol integer overflow")
7852 .kind,
7853 ActivityOptionsErrorKind::TimeoutOverflow
7854 );
7855 }
7856
7857 #[test]
7858 fn replayed_activity_retry_history_completes_without_duplicate_schedule() {
7859 let ctx = workflow_context(completed_retry_activity_history());
7860 let mut call =
7861 Box::pin(ctx.activity_with_options("flaky", retry_activity_options(), json!([])));
7862 let mut task_context = TaskContext::from_waker(noop_waker_ref());
7863
7864 assert!(matches!(
7865 call.as_mut().poll(&mut task_context),
7866 Poll::Ready(Ok(result)) if result == json!({"status": "recovered"})
7867 ));
7868 assert!(ctx.take_commands().expect("commands").is_empty());
7869 ctx.ensure_history_consumed().expect("history consumed");
7870 }
7871
7872 #[test]
7873 fn duplicate_non_retryable_types_use_one_command_and_replay_representation() {
7874 let mut options = retry_activity_options();
7875 options
7876 .retry_policy
7877 .as_mut()
7878 .expect("retry policy")
7879 .non_retryable_error_types
7880 .extend([" PermanentError ".to_string(), "PermanentError".to_string()]);
7881
7882 let new_ctx = workflow_context(Vec::new());
7883 let mut new_call =
7884 Box::pin(new_ctx.activity_with_options("flaky", options.clone(), json!([])));
7885 let mut task_context = TaskContext::from_waker(noop_waker_ref());
7886 assert!(matches!(
7887 new_call.as_mut().poll(&mut task_context),
7888 Poll::Pending
7889 ));
7890 let commands = new_ctx.take_commands().expect("commands");
7891 assert_eq!(commands.len(), 1);
7892 assert_eq!(
7893 commands[0]["retry_policy"]["non_retryable_error_types"],
7894 json!(["PermanentError"])
7895 );
7896
7897 let replay_ctx = workflow_context(completed_retry_activity_history());
7898 let mut replay_call =
7899 Box::pin(replay_ctx.activity_with_options("flaky", options, json!([])));
7900 assert!(matches!(
7901 replay_call.as_mut().poll(&mut task_context),
7902 Poll::Ready(Ok(result)) if result == json!({"status": "recovered"})
7903 ));
7904 assert!(replay_ctx.take_commands().expect("commands").is_empty());
7905 replay_ctx
7906 .ensure_history_consumed()
7907 .expect("history consumed");
7908 }
7909
7910 #[test]
7911 fn replayed_intermediate_retry_remains_pending_across_restarts() {
7912 let history = completed_retry_activity_history()
7913 .into_iter()
7914 .take(3)
7915 .collect::<Vec<_>>();
7916
7917 for _restart in 0..2 {
7918 let ctx = workflow_context(history.clone());
7919 let mut call =
7920 Box::pin(ctx.activity_with_options("flaky", retry_activity_options(), json!([])));
7921 let mut task_context = TaskContext::from_waker(noop_waker_ref());
7922 assert!(matches!(
7923 call.as_mut().poll(&mut task_context),
7924 Poll::Pending
7925 ));
7926 assert!(ctx.take_commands().expect("commands").is_empty());
7927 }
7928 }
7929
7930 #[test]
7931 fn replayed_activity_rejects_changed_queue_retry_and_every_timeout_field() {
7932 let mut changed_queue = retry_activity_options();
7933 changed_queue.task_queue = Some("different-queue".to_string());
7934
7935 let mut changed_max_attempts = retry_activity_options();
7936 let retry_policy = changed_max_attempts
7937 .retry_policy
7938 .as_mut()
7939 .expect("retry policy");
7940 retry_policy.max_attempts = Some(4);
7941
7942 let mut changed_backoff = retry_activity_options();
7943 let retry_policy = changed_backoff.retry_policy.as_mut().expect("retry policy");
7944 retry_policy.backoff = Some(ActivityBackoff::Explicit(vec![
7945 Duration::from_secs(3),
7946 Duration::from_secs(4),
7947 ]));
7948
7949 let mut changed_non_retryable_types = retry_activity_options();
7950 let retry_policy = changed_non_retryable_types
7951 .retry_policy
7952 .as_mut()
7953 .expect("retry policy");
7954 retry_policy.non_retryable_error_types = vec!["AnotherPermanentError".to_string()];
7955
7956 let mut changed_start_to_close = retry_activity_options();
7957 changed_start_to_close.start_to_close_timeout = Some(Duration::from_secs(31));
7958 let mut changed_schedule_to_start = retry_activity_options();
7959 changed_schedule_to_start.schedule_to_start_timeout = Some(Duration::from_secs(6));
7960 let mut changed_schedule_to_close = retry_activity_options();
7961 changed_schedule_to_close.schedule_to_close_timeout = Some(Duration::from_secs(91));
7962 let mut changed_heartbeat = retry_activity_options();
7963 changed_heartbeat.heartbeat_timeout = Some(Duration::from_secs(11));
7964
7965 let cases = [
7966 (changed_queue, "activity_task_queue_mismatch"),
7967 (changed_max_attempts, "activity_retry_policy_mismatch"),
7968 (changed_backoff, "activity_retry_policy_mismatch"),
7969 (
7970 changed_non_retryable_types,
7971 "activity_retry_policy_mismatch",
7972 ),
7973 (changed_start_to_close, "activity_retry_policy_mismatch"),
7974 (changed_schedule_to_start, "activity_retry_policy_mismatch"),
7975 (changed_schedule_to_close, "activity_retry_policy_mismatch"),
7976 (changed_heartbeat, "activity_retry_policy_mismatch"),
7977 ];
7978
7979 for (options, expected_reason) in cases {
7980 let ctx = workflow_context(completed_retry_activity_history());
7981 let mut call = Box::pin(ctx.activity_with_options("flaky", options, json!([])));
7982 let mut task_context = TaskContext::from_waker(noop_waker_ref());
7983 let Poll::Ready(Err(Error::NonDeterministicReplay(failure))) =
7984 call.as_mut().poll(&mut task_context)
7985 else {
7986 panic!("changed activity options must fail replay");
7987 };
7988 assert_eq!(failure.reason, expected_reason);
7989 assert_eq!(failure.sequence, Some(1));
7990 assert!(ctx.take_commands().expect("commands").is_empty());
7991 }
7992 }
7993
7994 #[test]
7995 fn replayed_activity_rejects_changed_execution_mode_and_snapshot_version() {
7996 let cases = [
7997 (
7998 "execution_mode",
7999 json!("local"),
8000 "activity_execution_mode_mismatch",
8001 ),
8002 (
8003 "snapshot_version",
8004 json!(2),
8005 "activity_retry_policy_mismatch",
8006 ),
8007 ];
8008
8009 for (field, value, expected_reason) in cases {
8010 let mut history = completed_retry_activity_history();
8011 let activity = history[0].payload["activity"]
8012 .as_object_mut()
8013 .expect("activity snapshot");
8014 if field == "execution_mode" {
8015 activity.insert(field.to_string(), value);
8016 } else {
8017 activity["retry_policy"]
8018 .as_object_mut()
8019 .expect("retry snapshot")
8020 .insert(field.to_string(), value);
8021 }
8022
8023 let ctx = workflow_context(history);
8024 let mut call =
8025 Box::pin(ctx.activity_with_options("flaky", retry_activity_options(), json!([])));
8026 let mut task_context = TaskContext::from_waker(noop_waker_ref());
8027 let Poll::Ready(Err(Error::NonDeterministicReplay(failure))) =
8028 call.as_mut().poll(&mut task_context)
8029 else {
8030 panic!("changed {field} must fail replay");
8031 };
8032 assert_eq!(failure.reason, expected_reason);
8033 assert_eq!(failure.sequence, Some(1));
8034 assert!(ctx.take_commands().expect("commands").is_empty());
8035 }
8036 }
8037
8038 #[test]
8039 fn replayed_legacy_activity_treats_missing_option_snapshot_as_unknown() {
8040 let mut history = completed_retry_activity_history();
8041 let activity = history[0].payload["activity"]
8042 .as_object_mut()
8043 .expect("activity snapshot");
8044 activity.remove("execution_mode");
8045 activity.remove("retry_policy");
8046
8047 let mut current = retry_activity_options();
8048 current.start_to_close_timeout = Some(Duration::from_secs(45));
8049 current.schedule_to_start_timeout = Some(Duration::from_secs(8));
8050 current.schedule_to_close_timeout = Some(Duration::from_secs(120));
8051 current.heartbeat_timeout = Some(Duration::from_secs(12));
8052
8053 let ctx = workflow_context(history);
8054 let mut call = Box::pin(ctx.activity_with_options("flaky", current, json!([])));
8055 let mut task_context = TaskContext::from_waker(noop_waker_ref());
8056 assert!(matches!(
8057 call.as_mut().poll(&mut task_context),
8058 Poll::Ready(Ok(result)) if result == json!({"status": "recovered"})
8059 ));
8060 assert!(ctx.take_commands().expect("commands").is_empty());
8061 ctx.ensure_history_consumed().expect("history consumed");
8062 }
8063
8064 #[test]
8065 fn terminal_activity_failed_after_start_returns_typed_failure() {
8066 let history = vec![
8067 history_event(
8068 "ActivityScheduled",
8069 json!({
8070 "sequence": 1,
8071 "activity_type": "flaky",
8072 "activity_execution_id": "act-terminal",
8073 "activity": {
8074 "id": "act-terminal",
8075 "sequence": 1,
8076 "type": "flaky",
8077 "queue": "critical-activities",
8078 "retry_policy": {
8079 "snapshot_version": 1,
8080 "max_attempts": 3,
8081 "backoff_seconds": [2, 4],
8082 "non_retryable_error_types": ["PermanentError"]
8083 }
8084 }
8085 }),
8086 ),
8087 history_event(
8088 "ActivityStarted",
8089 json!({
8090 "sequence": 1,
8091 "activity_type": "flaky",
8092 "activity_execution_id": "act-terminal",
8093 "activity_attempt_id": "attempt-1",
8094 "attempt_number": 1
8095 }),
8096 ),
8097 history_event(
8098 "ActivityFailed",
8099 json!({
8100 "sequence": 1,
8101 "activity_type": "flaky",
8102 "activity_execution_id": "act-terminal",
8103 "activity_attempt_id": "attempt-1",
8104 "attempt_number": 1,
8105 "failure_id": "failure-terminal",
8106 "failure_category": "activity",
8107 "exception_type": "PermanentError",
8108 "message": "cannot retry",
8109 "non_retryable": true
8110 }),
8111 ),
8112 ];
8113 let ctx = workflow_context(history);
8114 let mut call =
8115 Box::pin(ctx.activity_with_options("flaky", retry_activity_options(), json!([])));
8116 let mut task_context = TaskContext::from_waker(noop_waker_ref());
8117
8118 let Poll::Ready(Err(Error::ActivityFailed(failure))) =
8119 call.as_mut().poll(&mut task_context)
8120 else {
8121 panic!("terminal ActivityFailed must settle the activity future");
8122 };
8123 assert_eq!(failure.kind, ActivityFailureKind::Failed);
8124 assert_eq!(
8125 failure.activity_execution_id.as_deref(),
8126 Some("act-terminal")
8127 );
8128 assert_eq!(failure.exception_type.as_deref(), Some("PermanentError"));
8129 assert!(failure.non_retryable);
8130 assert!(ctx.take_commands().expect("commands").is_empty());
8131 ctx.ensure_history_consumed().expect("history consumed");
8132 }
8133
8134 #[test]
8135 fn activity_terminal_events_return_machine_readable_failures() {
8136 let cases = [
8137 (
8138 "ActivityFailed",
8139 json!({
8140 "sequence": 1,
8141 "activity_type": "charge-card",
8142 "activity_execution_id": "act-1",
8143 "activity_attempt_id": "attempt-2",
8144 "attempt_number": 2,
8145 "failure_id": "failure-1",
8146 "failure_category": "activity",
8147 "exception_type": "PaymentDeclined",
8148 "exception_class": "payments.PaymentDeclined",
8149 "message": "card declined",
8150 "non_retryable": true
8151 }),
8152 ActivityFailureKind::Failed,
8153 "activity",
8154 ),
8155 (
8156 "ActivityCancelled",
8157 json!({
8158 "sequence": 1,
8159 "activity_type": "charge-card",
8160 "activity_execution_id": "act-1",
8161 "activity_attempt_id": "attempt-1"
8162 }),
8163 ActivityFailureKind::Cancelled,
8164 "cancelled",
8165 ),
8166 ];
8167
8168 for (event_type, payload, expected_kind, expected_reason) in cases {
8169 let ctx = workflow_context(vec![history_event(event_type, payload)]);
8170 let mut call = Box::pin(ctx.activity("charge-card", json!([])));
8171 let mut task_context = TaskContext::from_waker(noop_waker_ref());
8172 let Poll::Ready(Err(Error::ActivityFailed(failure))) =
8173 call.as_mut().poll(&mut task_context)
8174 else {
8175 panic!("expected terminal activity failure");
8176 };
8177 assert_eq!(failure.kind, expected_kind);
8178 assert_eq!(failure.reason, expected_reason);
8179 assert_eq!(failure.activity_execution_id.as_deref(), Some("act-1"));
8180 assert_eq!(failure.activity_type.as_deref(), Some("charge-card"));
8181 }
8182 }
8183
8184 #[test]
8185 fn every_activity_timeout_class_is_typed() {
8186 for timeout_kind in [
8187 "start_to_close",
8188 "schedule_to_start",
8189 "schedule_to_close",
8190 "heartbeat",
8191 ] {
8192 let ctx = workflow_context(vec![history_event(
8193 "ActivityTimedOut",
8194 json!({
8195 "sequence": 1,
8196 "activity_type": "slow",
8197 "activity_execution_id": "act-timeout",
8198 "activity_attempt_id": "attempt-timeout",
8199 "failure_category": "timeout",
8200 "timeout_kind": timeout_kind,
8201 "message": "deadline expired"
8202 }),
8203 )]);
8204 let mut call = Box::pin(ctx.activity("slow", json!([])));
8205 let mut task_context = TaskContext::from_waker(noop_waker_ref());
8206 let Poll::Ready(Err(Error::ActivityFailed(failure))) =
8207 call.as_mut().poll(&mut task_context)
8208 else {
8209 panic!("expected timeout failure");
8210 };
8211 assert_eq!(failure.kind, ActivityFailureKind::TimedOut);
8212 assert_eq!(failure.reason, timeout_kind);
8213 assert_eq!(failure.timeout_kind.as_deref(), Some(timeout_kind));
8214 assert_eq!(failure.failure_category.as_deref(), Some("timeout"));
8215 }
8216 }
8217
8218 #[test]
8219 fn workflow_sleep_emits_one_durable_timer_and_rounds_up() {
8220 let ctx = workflow_context(Vec::new());
8221 let mut sleep = Box::pin(ctx.sleep(Duration::from_millis(1_001)));
8222 let mut task_context = TaskContext::from_waker(noop_waker_ref());
8223
8224 assert!(matches!(
8225 sleep.as_mut().poll(&mut task_context),
8226 Poll::Pending
8227 ));
8228 assert!(matches!(
8229 sleep.as_mut().poll(&mut task_context),
8230 Poll::Pending
8231 ));
8232
8233 let commands = ctx.take_commands().expect("timer command");
8234 assert_eq!(
8235 commands,
8236 vec![json!({
8237 "type": "start_timer",
8238 "delay_seconds": 2,
8239 })]
8240 );
8241 }
8242
8243 #[test]
8244 fn workflow_sleep_replays_matching_schedule_and_fire_without_a_command() {
8245 let history = vec![
8246 history_event(
8247 "TimerScheduled",
8248 json!({
8249 "sequence": 1,
8250 "timer_id": "timer-1",
8251 "delay_seconds": 5,
8252 "fire_at": "2026-07-11T12:00:05Z",
8253 }),
8254 ),
8255 history_event(
8256 "TimerFired",
8257 json!({
8258 "sequence": 1,
8259 "timer_id": "timer-1",
8260 "delay_seconds": 5,
8261 "fire_at": "2026-07-11T12:00:05Z",
8262 "fired_at": "2026-07-11T12:00:05Z",
8263 }),
8264 ),
8265 ];
8266
8267 for _restart in 0..2 {
8268 let ctx = workflow_context(history.clone());
8269 let mut sleep = Box::pin(ctx.sleep(Duration::from_secs(5)));
8270 let mut task_context = TaskContext::from_waker(noop_waker_ref());
8271 assert!(matches!(
8272 sleep.as_mut().poll(&mut task_context),
8273 Poll::Ready(Ok(()))
8274 ));
8275 assert!(ctx.take_commands().expect("commands").is_empty());
8276 ctx.ensure_history_consumed().expect("history consumed");
8277 }
8278 }
8279
8280 #[test]
8281 fn workflow_sleep_rejects_changed_delay_during_replay() {
8282 let ctx = workflow_context(vec![
8283 history_event(
8284 "TimerScheduled",
8285 json!({"sequence": 1, "timer_id": "timer-1", "delay_seconds": 5}),
8286 ),
8287 history_event(
8288 "TimerFired",
8289 json!({"sequence": 1, "timer_id": "timer-1", "delay_seconds": 5}),
8290 ),
8291 ]);
8292 let mut sleep = Box::pin(ctx.sleep(Duration::from_secs(500)));
8293 let mut task_context = TaskContext::from_waker(noop_waker_ref());
8294
8295 let Poll::Ready(Err(Error::NonDeterministicReplay(failure))) =
8296 sleep.as_mut().poll(&mut task_context)
8297 else {
8298 panic!("changed timer delay must be rejected");
8299 };
8300 assert_eq!(failure.reason, "timer_delay_mismatch");
8301 assert_eq!(failure.sequence, Some(1));
8302 }
8303
8304 #[test]
8305 fn workflow_history_rejects_unpaired_or_mismatched_timer_events() {
8306 let lone_fire = WorkflowState::new(
8307 vec![history_event(
8308 "TimerFired",
8309 json!({"sequence": 1, "timer_id": "timer-1", "delay_seconds": 5}),
8310 )],
8311 "rust-workers".to_string(),
8312 JSON_CODEC.to_string(),
8313 None,
8314 )
8315 .expect_err("TimerFired requires TimerScheduled");
8316 assert!(matches!(
8317 lone_fire,
8318 Error::NonDeterministicReplay(ReplayFailure { ref reason, .. })
8319 if reason == "timer_schedule_missing_or_duplicate"
8320 ));
8321
8322 let wrong_identity = WorkflowState::new(
8323 vec![
8324 history_event(
8325 "TimerScheduled",
8326 json!({"sequence": 1, "timer_id": "timer-1", "delay_seconds": 5}),
8327 ),
8328 history_event(
8329 "TimerFired",
8330 json!({"sequence": 1, "timer_id": "timer-2", "delay_seconds": 5}),
8331 ),
8332 ],
8333 "rust-workers".to_string(),
8334 JSON_CODEC.to_string(),
8335 None,
8336 )
8337 .expect_err("fire must match scheduled timer identity");
8338 assert!(matches!(
8339 wrong_identity,
8340 Error::NonDeterministicReplay(ReplayFailure { ref reason, .. })
8341 if reason == "timer_identity_mismatch"
8342 ));
8343
8344 let duplicate_fire = WorkflowState::new(
8345 vec![
8346 history_event(
8347 "TimerScheduled",
8348 json!({"sequence": 1, "timer_id": "timer-1", "delay_seconds": 5}),
8349 ),
8350 history_event(
8351 "TimerFired",
8352 json!({"sequence": 1, "timer_id": "timer-1", "delay_seconds": 5}),
8353 ),
8354 history_event(
8355 "TimerFired",
8356 json!({"sequence": 1, "timer_id": "timer-1", "delay_seconds": 5}),
8357 ),
8358 ],
8359 "rust-workers".to_string(),
8360 JSON_CODEC.to_string(),
8361 None,
8362 )
8363 .expect_err("a durable timer cannot fire twice");
8364 assert!(matches!(
8365 duplicate_fire,
8366 Error::NonDeterministicReplay(ReplayFailure { ref reason, .. })
8367 if reason == "duplicate_timer_fire"
8368 ));
8369
8370 let wrong_fired_delay = WorkflowState::new(
8371 vec![
8372 history_event(
8373 "TimerScheduled",
8374 json!({"sequence": 1, "timer_id": "timer-1", "delay_seconds": 5}),
8375 ),
8376 history_event(
8377 "TimerFired",
8378 json!({"sequence": 1, "timer_id": "timer-1", "delay_seconds": 6}),
8379 ),
8380 ],
8381 "rust-workers".to_string(),
8382 JSON_CODEC.to_string(),
8383 None,
8384 )
8385 .expect_err("timer schedule and fire delays must agree");
8386 assert!(matches!(
8387 wrong_fired_delay,
8388 Error::NonDeterministicReplay(ReplayFailure { ref reason, .. })
8389 if reason == "timer_history_delay_mismatch"
8390 ));
8391 }
8392
8393 #[test]
8394 fn replay_rejects_activity_moved_before_recorded_timer() {
8395 let ctx = workflow_context(vec![
8396 history_event(
8397 "TimerScheduled",
8398 json!({"sequence": 1, "timer_id": "timer-1", "delay_seconds": 5}),
8399 ),
8400 history_event(
8401 "TimerFired",
8402 json!({"sequence": 1, "timer_id": "timer-1", "delay_seconds": 5}),
8403 ),
8404 history_event(
8405 "ActivityCompleted",
8406 json!({
8407 "sequence": 2,
8408 "activity_type": "after-timer",
8409 "payload_codec": "json",
8410 "result": {"codec": "json", "blob": "\"done\""},
8411 }),
8412 ),
8413 ]);
8414 let mut activity = Box::pin(ctx.activity("after-timer", json!([])));
8415 let mut task_context = TaskContext::from_waker(noop_waker_ref());
8416
8417 let Poll::Ready(Err(Error::NonDeterministicReplay(failure))) =
8418 activity.as_mut().poll(&mut task_context)
8419 else {
8420 panic!("reordered durable command must be rejected");
8421 };
8422 assert_eq!(failure.reason, "recorded_command_mismatch");
8423 assert_eq!(failure.sequence, Some(1));
8424 assert_eq!(failure.expected.as_deref(), Some("timer"));
8425 assert_eq!(failure.actual.as_deref(), Some("activity:after-timer"));
8426 }
8427
8428 #[test]
8429 fn replay_orders_signal_waits_and_timers_in_one_command_stream() {
8430 let signal_then_timer = vec![
8431 history_event(
8432 "ConditionWaitOpened",
8433 json!({"sequence": 1, "condition_key": "signal:go"}),
8434 ),
8435 history_event(
8436 "SignalReceived",
8437 json!({
8438 "signal_name": "go",
8439 "arguments": ["now"],
8440 }),
8441 ),
8442 history_event(
8443 "TimerScheduled",
8444 json!({"sequence": 2, "timer_id": "timer-2", "delay_seconds": 5}),
8445 ),
8446 history_event(
8447 "TimerFired",
8448 json!({"sequence": 2, "timer_id": "timer-2", "delay_seconds": 5}),
8449 ),
8450 ];
8451
8452 let ctx = workflow_context(signal_then_timer.clone());
8453 let mut signal = Box::pin(ctx.wait_signal("go"));
8454 let mut task_context = TaskContext::from_waker(noop_waker_ref());
8455 assert!(matches!(
8456 signal.as_mut().poll(&mut task_context),
8457 Poll::Ready(Ok(arguments)) if arguments == vec![json!("now")]
8458 ));
8459 let mut timer = Box::pin(ctx.sleep(Duration::from_secs(5)));
8460 assert!(matches!(
8461 timer.as_mut().poll(&mut task_context),
8462 Poll::Ready(Ok(()))
8463 ));
8464 ctx.ensure_history_consumed()
8465 .expect("signal and timer history consumed in order");
8466
8467 let reordered = workflow_context(signal_then_timer);
8468 let mut timer_first = Box::pin(reordered.sleep(Duration::from_secs(5)));
8469 let Poll::Ready(Err(Error::NonDeterministicReplay(failure))) =
8470 timer_first.as_mut().poll(&mut task_context)
8471 else {
8472 panic!("timer cannot consume signal-wait-first history");
8473 };
8474 assert_eq!(failure.reason, "recorded_command_mismatch");
8475 assert_eq!(failure.sequence, Some(1));
8476 assert_eq!(failure.expected.as_deref(), Some("signal wait"));
8477
8478 let timer_then_signal = vec![
8479 history_event(
8480 "TimerScheduled",
8481 json!({"sequence": 1, "timer_id": "timer-1", "delay_seconds": 5}),
8482 ),
8483 history_event(
8484 "TimerFired",
8485 json!({"sequence": 1, "timer_id": "timer-1", "delay_seconds": 5}),
8486 ),
8487 history_event(
8488 "ConditionWaitOpened",
8489 json!({"sequence": 2, "condition_key": "signal:go"}),
8490 ),
8491 history_event(
8492 "SignalReceived",
8493 json!({"signal_name": "go", "arguments": []}),
8494 ),
8495 ];
8496 let reordered = workflow_context(timer_then_signal);
8497 let mut signal_first = Box::pin(reordered.wait_signal("go"));
8498 let Poll::Ready(Err(Error::NonDeterministicReplay(failure))) =
8499 signal_first.as_mut().poll(&mut task_context)
8500 else {
8501 panic!("signal wait cannot consume timer-first history");
8502 };
8503 assert_eq!(failure.reason, "recorded_command_mismatch");
8504 assert_eq!(failure.sequence, Some(1));
8505 assert_eq!(failure.expected.as_deref(), Some("timer"));
8506 }
8507
8508 #[test]
8509 fn workflow_history_rejects_duplicate_or_colliding_command_sequences() {
8510 let duplicate_timer = WorkflowState::new(
8511 vec![
8512 history_event(
8513 "TimerScheduled",
8514 json!({"sequence": 1, "timer_id": "timer-1", "delay_seconds": 5}),
8515 ),
8516 history_event(
8517 "TimerScheduled",
8518 json!({"sequence": 1, "timer_id": "timer-2", "delay_seconds": 5}),
8519 ),
8520 ],
8521 "rust-workers".to_string(),
8522 JSON_CODEC.to_string(),
8523 None,
8524 )
8525 .expect_err("one workflow sequence cannot schedule two timers");
8526 assert!(matches!(
8527 duplicate_timer,
8528 Error::NonDeterministicReplay(ReplayFailure { ref reason, .. })
8529 if reason == "timer_schedule_missing_or_duplicate"
8530 ));
8531
8532 let colliding_kinds = WorkflowState::new(
8533 vec![
8534 history_event(
8535 "TimerScheduled",
8536 json!({"sequence": 1, "timer_id": "timer-1", "delay_seconds": 5}),
8537 ),
8538 history_event(
8539 "ActivityCompleted",
8540 json!({"sequence": 1, "activity_type": "same-sequence"}),
8541 ),
8542 ],
8543 "rust-workers".to_string(),
8544 JSON_CODEC.to_string(),
8545 None,
8546 )
8547 .expect_err("one workflow sequence cannot identify two command kinds");
8548 assert!(matches!(
8549 colliding_kinds,
8550 Error::NonDeterministicReplay(ReplayFailure { ref reason, .. })
8551 if reason == "durable_command_sequence_collision"
8552 ));
8553
8554 let duplicate_signal_wait = WorkflowState::new(
8555 vec![
8556 history_event(
8557 "SignalWaitOpened",
8558 json!({"sequence": 1, "signal_name": "go"}),
8559 ),
8560 history_event(
8561 "SignalWaitOpened",
8562 json!({"sequence": 1, "signal_name": "go"}),
8563 ),
8564 ],
8565 "rust-workers".to_string(),
8566 JSON_CODEC.to_string(),
8567 None,
8568 )
8569 .expect_err("one workflow sequence cannot open two signal waits");
8570 assert!(matches!(
8571 duplicate_signal_wait,
8572 Error::NonDeterministicReplay(ReplayFailure { ref reason, .. })
8573 if reason == "duplicate_signal_wait_open"
8574 ));
8575 }
8576
8577 #[test]
8578 fn workflow_history_rejects_changed_numeric_command_sequence() {
8579 let result = encode_value_envelope(&json!({"captured": true}), JSON_CODEC)
8580 .expect("side-effect result");
8581 let error = WorkflowState::new(
8582 vec![history_event(
8583 "SideEffectRecorded",
8584 json!({"sequence": 99, "result": result}),
8585 )],
8586 "rust-workers".to_string(),
8587 JSON_CODEC.to_string(),
8588 None,
8589 )
8590 .expect_err("the first durable command must have sequence one");
8591
8592 let Error::NonDeterministicReplay(failure) = error else {
8593 panic!("expected typed replay failure");
8594 };
8595 assert_eq!(failure.reason, "durable_command_sequence_mismatch");
8596 assert_eq!(failure.sequence, Some(99));
8597 assert_eq!(failure.expected.as_deref(), Some("1"));
8598 assert_eq!(failure.actual.as_deref(), Some("99"));
8599 }
8600
8601 #[test]
8602 fn workflow_sleep_rejects_unrepresentable_rounded_duration() {
8603 let ctx = workflow_context(Vec::new());
8604 let mut sleep = Box::pin(ctx.start_timer(Duration::new(u64::MAX, 1)));
8605 let mut task_context = TaskContext::from_waker(noop_waker_ref());
8606 assert!(matches!(
8607 sleep.as_mut().poll(&mut task_context),
8608 Poll::Ready(Err(Error::TimerDurationOverflow))
8609 ));
8610 assert!(ctx.take_commands().expect("commands").is_empty());
8611 }
8612
8613 #[test]
8614 fn workflow_task_replay_completes_without_rescheduling_recorded_commands() {
8615 let client = Client::new("http://127.0.0.1:8080").expect("client");
8616 let mut worker = Worker::new(client, "rust-workers");
8617 worker.register_workflow("rust.timer", |ctx, _input| async move {
8618 ctx.sleep(Duration::from_secs(5)).await?;
8619 ctx.activity("after-timer", json!([])).await
8620 });
8621
8622 let task = |history_events| WorkflowTask {
8623 task_id: "wft-rust-timer-1".to_string(),
8624 workflow_id: Some("wf-rust-timer".to_string()),
8625 run_id: Some("run-rust-timer".to_string()),
8626 workflow_type: "rust.timer".to_string(),
8627 payload_codec: JSON_CODEC.to_string(),
8628 arguments: Some(json!({"codec": "json", "blob": "[]"})),
8629 history_events,
8630 total_history_events: None,
8631 history_size_bytes: None,
8632 continue_as_new_recommended: None,
8633 history_budget_pressure: None,
8634 next_history_page_token: None,
8635 workflow_task_attempt: 1,
8636 workflow_signal_id: None,
8637 signal_name: None,
8638 signal_arguments: None,
8639 lease_owner: Some("rust-worker".to_string()),
8640 };
8641
8642 let initial = worker
8643 .execute_workflow_task(task(Vec::new()))
8644 .expect("initial timer task");
8645 assert_eq!(
8646 initial,
8647 vec![json!({"type": "start_timer", "delay_seconds": 5})]
8648 );
8649
8650 let replayed = worker
8651 .execute_workflow_task(task(vec![
8652 history_event(
8653 "TimerScheduled",
8654 json!({"sequence": 1, "timer_id": "timer-1", "delay_seconds": 5}),
8655 ),
8656 history_event(
8657 "TimerFired",
8658 json!({"sequence": 1, "timer_id": "timer-1", "delay_seconds": 5}),
8659 ),
8660 history_event(
8661 "ActivityCompleted",
8662 json!({
8663 "sequence": 2,
8664 "activity_type": "after-timer",
8665 "payload_codec": "json",
8666 "result": {"codec": "json", "blob": "\"done\""},
8667 }),
8668 ),
8669 ]))
8670 .expect("replayed workflow task");
8671 assert_eq!(replayed.len(), 1);
8672 assert_eq!(replayed[0]["type"], "complete_workflow");
8673 assert_eq!(
8674 decode_wire_value(&replayed[0]["result"], JSON_CODEC).expect("result"),
8675 json!("done")
8676 );
8677 }
8678
8679 #[test]
8680 fn workflow_continue_as_new_emits_arguments_type_and_queue_once() {
8681 let client = Client::new("http://127.0.0.1:8080").expect("client");
8682 let mut worker = Worker::new(client, "rust-workers");
8683 worker.register_workflow("rust.continue", |ctx, _input| async move {
8684 ctx.continue_as_new_with_options(
8685 ContinueAsNewOptions::new()
8686 .workflow_type("rust.next")
8687 .task_queue("next-workers"),
8688 json!([2, {"cursor": "next"}]),
8689 )
8690 });
8691
8692 let commands = worker
8693 .execute_workflow_task(workflow_task("rust.continue", Vec::new(), DEFAULT_CODEC))
8694 .expect("continue-as-new command");
8695
8696 assert_eq!(commands.len(), 1);
8697 assert_eq!(commands[0]["type"], "continue_as_new");
8698 assert_eq!(commands[0]["workflow_type"], "rust.next");
8699 assert_eq!(commands[0]["queue"], "next-workers");
8700 assert_eq!(
8701 decode_wire_value(&commands[0]["arguments"], DEFAULT_CODEC)
8702 .expect("continue-as-new arguments"),
8703 json!([2, {"cursor": "next"}])
8704 );
8705 }
8706
8707 #[test]
8708 fn recorded_continue_as_new_is_consumed_without_duplicate_successor_command() {
8709 let client = Client::new("http://127.0.0.1:8080").expect("client");
8710 let mut worker = Worker::new(client, "rust-workers");
8711 worker.register_workflow("rust.continue", |ctx, _input| async move {
8712 ctx.continue_as_new(json!([2]))
8713 });
8714 let task = workflow_task(
8715 "rust.continue",
8716 vec![history_event(
8717 "WorkflowContinuedAsNew",
8718 json!({"sequence": 1, "continued_to_run_id": "run-next"}),
8719 )],
8720 JSON_CODEC,
8721 );
8722
8723 for _worker_restart_or_redelivery in 0..2 {
8724 let commands = worker
8725 .execute_workflow_task(task.clone())
8726 .expect("recorded transition replays");
8727 assert!(
8728 commands.is_empty(),
8729 "replay must not emit another successor"
8730 );
8731 }
8732 }
8733
8734 #[test]
8735 fn continue_as_new_rejects_invalid_overrides_before_emitting_a_command() {
8736 let ctx = workflow_context(Vec::new());
8737 let error = ctx
8738 .continue_as_new_with_options(ContinueAsNewOptions::new().task_queue(" "), json!([1]))
8739 .expect_err("blank queue must be rejected");
8740
8741 let Error::InvalidContinueAsNewOptions(error) = error else {
8742 panic!("expected typed continue-as-new validation error");
8743 };
8744 assert_eq!(error.field, "task_queue");
8745 assert!(ctx.take_commands().expect("commands").is_empty());
8746 }
8747
8748 #[test]
8749 fn workflow_context_exposes_server_history_budget() {
8750 let client = Client::new("http://127.0.0.1:8080").expect("client");
8751 let mut worker = Worker::new(client, "rust-workers");
8752 worker.register_workflow("rust.history-budget", |ctx, _input| async move {
8753 let budget = ctx.history_budget()?;
8754 Ok(json!({
8755 "events": budget.event_count,
8756 "bytes": budget.size_bytes,
8757 "recommended": budget.continue_as_new_recommended,
8758 "pressure": budget.pressure,
8759 }))
8760 });
8761 let task: WorkflowTask = serde_json::from_value(json!({
8762 "task_id": "task-history-budget",
8763 "workflow_type": "rust.history-budget",
8764 "payload_codec": JSON_CODEC,
8765 "history_events": [],
8766 "total_history_events": 480,
8767 "history_size_bytes": 1_048_576,
8768 "continue_as_new_recommended": true,
8769 "history_budget_pressure": "continue_as_new_recommended",
8770 }))
8771 .expect("published workflow task");
8772
8773 let commands = worker
8774 .execute_workflow_task(task)
8775 .expect("history-budget workflow");
8776 let result = decode_wire_value(&commands[0]["result"], JSON_CODEC).expect("result");
8777 assert_eq!(result["events"], 480);
8778 assert_eq!(result["bytes"], 1_048_576);
8779 assert_eq!(result["recommended"], true);
8780 assert_eq!(result["pressure"], "continue_as_new_recommended");
8781 }
8782
8783 #[test]
8784 fn uncaught_workflow_handler_error_emits_terminal_failure_command() {
8785 let client = Client::new("http://127.0.0.1:8080").expect("client");
8786 let mut worker = Worker::new(client, "rust-workers");
8787 worker.register_workflow("rust.failing", |_ctx, _input| async move {
8788 Err(Error::Codec("rust_conformance_failure".to_string()))
8789 });
8790 let task = WorkflowTask {
8791 task_id: "wft-rust-failing-1".to_string(),
8792 workflow_id: Some("wf-rust-failing".to_string()),
8793 run_id: Some("run-rust-failing".to_string()),
8794 workflow_type: "rust.failing".to_string(),
8795 payload_codec: JSON_CODEC.to_string(),
8796 arguments: Some(encode_value_envelope(&json!([]), JSON_CODEC).expect("input")),
8797 history_events: Vec::new(),
8798 total_history_events: Some(0),
8799 history_size_bytes: None,
8800 continue_as_new_recommended: None,
8801 history_budget_pressure: None,
8802 next_history_page_token: None,
8803 workflow_task_attempt: 1,
8804 workflow_signal_id: None,
8805 signal_name: None,
8806 signal_arguments: None,
8807 lease_owner: Some("rust-worker".to_string()),
8808 };
8809
8810 let commands = worker
8811 .execute_workflow_task(task)
8812 .expect("handler failure becomes a workflow command");
8813
8814 assert_eq!(commands.len(), 1);
8815 assert_eq!(commands[0]["type"], "fail_workflow");
8816 assert_eq!(commands[0]["exception_type"], "RustWorkflowError");
8817 assert_eq!(commands[0]["exception_class"], "durable_workflow::Error");
8818 assert_eq!(commands[0]["non_retryable"], false);
8819 assert_eq!(
8820 commands[0]["message"],
8821 "codec error: rust_conformance_failure"
8822 );
8823 assert_eq!(
8824 commands[0]["exception"]["message"],
8825 "codec error: rust_conformance_failure"
8826 );
8827 }
8828
8829 #[test]
8830 fn ordinary_handler_error_preserves_commands_queued_in_the_same_decision() {
8831 let client = Client::new("http://127.0.0.1:8080").expect("client");
8832 let mut worker = Worker::new(client, "rust-workers");
8833 worker.register_workflow("rust.failing-after-side-effect", |ctx, _input| async move {
8834 let _: String = ctx.side_effect(|| "captured".to_string())?;
8835 Err(Error::WorkerLoop("application failure".to_string()))
8836 });
8837
8838 let commands = worker
8839 .execute_workflow_task(workflow_task(
8840 "rust.failing-after-side-effect",
8841 Vec::new(),
8842 JSON_CODEC,
8843 ))
8844 .expect("ordinary failure remains a workflow decision");
8845
8846 assert_eq!(commands.len(), 2);
8847 assert_eq!(commands[0]["type"], "record_side_effect");
8848 assert_eq!(commands[1]["type"], "fail_workflow");
8849 }
8850
8851 #[test]
8852 fn handler_error_cannot_hide_an_unconsumed_committed_side_effect() {
8853 let client = Client::new("http://127.0.0.1:8080").expect("client");
8854 let mut worker = Worker::new(client, "rust-workers");
8855 worker.register_workflow("rust.removed-side-effect", |_ctx, _input| async move {
8856 Err(Error::WorkerLoop("application failure".to_string()))
8857 });
8858 let result =
8859 encode_value_envelope(&json!("committed"), JSON_CODEC).expect("side-effect result");
8860
8861 let error = worker
8862 .execute_workflow_task(workflow_task(
8863 "rust.removed-side-effect",
8864 vec![history_event(
8865 "SideEffectRecorded",
8866 json!({"sequence": 1, "result": result}),
8867 )],
8868 JSON_CODEC,
8869 ))
8870 .expect_err("removed committed history must not become fail_workflow");
8871
8872 let Error::NonDeterministicReplay(failure) = error else {
8873 panic!("expected typed replay failure");
8874 };
8875 assert_eq!(failure.reason, "recorded_commands_unconsumed");
8876 assert_eq!(failure.sequence, Some(1));
8877 assert_eq!(failure.expected.as_deref(), Some("side effect"));
8878 }
8879
8880 #[test]
8881 fn replay_error_discards_side_effect_queued_before_incompatible_marker_check() {
8882 let client = Client::new("http://127.0.0.1:8080").expect("client");
8883 let mut worker = Worker::new(client, "rust-workers");
8884 worker.register_workflow(
8885 "rust.side-effect-before-marker-error",
8886 |ctx, _input| async move {
8887 assert_eq!(ctx.get_version("restart-safe", 1, 1)?, 1);
8888 let _: String = ctx.side_effect(|| "must-not-commit".to_string())?;
8889 ctx.get_version("restart-safe", 2, 2)?;
8890 Ok(Value::Null)
8891 },
8892 );
8893
8894 let error = worker
8895 .execute_workflow_task(workflow_task(
8896 "rust.side-effect-before-marker-error",
8897 vec![history_event(
8898 "VersionMarkerRecorded",
8899 json!({
8900 "sequence": 1,
8901 "change_id": "restart-safe",
8902 "version": 1,
8903 "min_supported": 1,
8904 "max_supported": 1,
8905 }),
8906 )],
8907 JSON_CODEC,
8908 ))
8909 .expect_err("replay error must return no queued workflow commands");
8910
8911 let Error::NonDeterministicReplay(failure) = error else {
8912 panic!("expected typed replay failure");
8913 };
8914 assert_eq!(failure.reason, "version_marker_incompatible_range");
8915 assert_eq!(failure.sequence, Some(1));
8916 }
8917
8918 #[test]
8919 fn workflow_task_replay_keeps_recorded_unfired_timer_pending_without_rescheduling() {
8920 let client = Client::new("http://127.0.0.1:8080").expect("client");
8921 let mut worker = Worker::new(client, "rust-workers");
8922 worker.register_workflow("rust.timer.pending", |ctx, _input| async move {
8923 ctx.sleep(Duration::from_secs(5)).await?;
8924 Ok(json!({"status": "timer fired"}))
8925 });
8926
8927 let task = WorkflowTask {
8928 task_id: "wft-rust-timer-pending".to_string(),
8929 workflow_id: Some("wf-rust-timer".to_string()),
8930 run_id: Some("run-rust-timer".to_string()),
8931 workflow_type: "rust.timer.pending".to_string(),
8932 payload_codec: JSON_CODEC.to_string(),
8933 arguments: Some(json!({"codec": "json", "blob": "[]"})),
8934 history_events: vec![history_event(
8935 "TimerScheduled",
8936 json!({"sequence": 1, "timer_id": "timer-1", "delay_seconds": 5}),
8937 )],
8938 total_history_events: Some(1),
8939 history_size_bytes: None,
8940 continue_as_new_recommended: None,
8941 history_budget_pressure: None,
8942 next_history_page_token: None,
8943 workflow_task_attempt: 1,
8944 workflow_signal_id: None,
8945 signal_name: None,
8946 signal_arguments: None,
8947 lease_owner: Some("rust-worker".to_string()),
8948 };
8949
8950 for _redelivery_or_restart in 0..2 {
8951 let commands = worker
8952 .execute_workflow_task(task.clone())
8953 .expect("recorded timer remains pending");
8954 assert!(
8955 commands.is_empty(),
8956 "recorded timer must not be rescheduled"
8957 );
8958 }
8959 }
8960
8961 #[test]
8962 fn workflow_task_rejects_recorded_command_removed_from_workflow_code() {
8963 let client = Client::new("http://127.0.0.1:8080").expect("client");
8964 let mut worker = Worker::new(client, "rust-workers");
8965 worker.register_workflow("rust.timer.removed", |_ctx, _input| async move {
8966 Ok(json!({"status": "completed"}))
8967 });
8968 let task = WorkflowTask {
8969 task_id: "wft-rust-timer-removed".to_string(),
8970 workflow_id: Some("wf-rust-timer".to_string()),
8971 run_id: Some("run-rust-timer".to_string()),
8972 workflow_type: "rust.timer.removed".to_string(),
8973 payload_codec: JSON_CODEC.to_string(),
8974 arguments: Some(json!({"codec": "json", "blob": "[]"})),
8975 history_events: vec![
8976 history_event(
8977 "TimerScheduled",
8978 json!({"sequence": 1, "timer_id": "timer-1", "delay_seconds": 5}),
8979 ),
8980 history_event(
8981 "TimerFired",
8982 json!({"sequence": 1, "timer_id": "timer-1", "delay_seconds": 5}),
8983 ),
8984 ],
8985 total_history_events: Some(2),
8986 history_size_bytes: None,
8987 continue_as_new_recommended: None,
8988 history_budget_pressure: None,
8989 next_history_page_token: None,
8990 workflow_task_attempt: 1,
8991 workflow_signal_id: None,
8992 signal_name: None,
8993 signal_arguments: None,
8994 lease_owner: Some("rust-worker".to_string()),
8995 };
8996
8997 let Error::NonDeterministicReplay(failure) = worker
8998 .execute_workflow_task(task)
8999 .expect_err("removed timer must fail replay")
9000 else {
9001 panic!("expected typed replay failure");
9002 };
9003 assert_eq!(failure.reason, "recorded_commands_unconsumed");
9004 assert_eq!(failure.sequence, Some(1));
9005 }
9006
9007 #[test]
9008 fn workflow_context_emits_explicit_child_workflow_contract() {
9009 let ctx = WorkflowContext {
9010 state: Arc::new(Mutex::new(
9011 WorkflowState::new_with_identity(
9012 Vec::new(),
9013 Some("wf-parent".to_string()),
9014 Some("run-parent".to_string()),
9015 "parent-workers".to_string(),
9016 JSON_CODEC.to_string(),
9017 None,
9018 )
9019 .expect("workflow state"),
9020 )),
9021 };
9022 let options = ChildWorkflowOptions::new("python-workers")
9023 .parent_close_policy(ParentClosePolicy::RequestCancel)
9024 .retry_policy(ChildWorkflowRetryPolicy {
9025 max_attempts: Some(3),
9026 backoff_seconds: vec![1, 5],
9027 non_retryable_error_types: vec!["ValidationError".to_string()],
9028 })
9029 .execution_timeout_seconds(600)
9030 .run_timeout_seconds(120);
9031 let mut call = Box::pin(ctx.start_child_workflow(
9032 "python.fulfil-order",
9033 options,
9034 json!([{"order_id": "order-42"}]),
9035 ));
9036 let mut task_context = TaskContext::from_waker(noop_waker_ref());
9037
9038 assert!(matches!(
9039 call.as_mut().poll(&mut task_context),
9040 Poll::Pending
9041 ));
9042 let commands = ctx.take_commands().expect("commands");
9043 assert_eq!(commands.len(), 1);
9044 let command = &commands[0];
9045 assert_eq!(command["type"], "start_child_workflow");
9046 assert_eq!(command["workflow_type"], "python.fulfil-order");
9047 assert_eq!(command["queue"], "python-workers");
9048 assert_eq!(command["parent_close_policy"], "request_cancel");
9049 assert_eq!(command["retry_policy"]["max_attempts"], 3);
9050 assert_eq!(command["execution_timeout_seconds"], 600);
9051 assert_eq!(command["run_timeout_seconds"], 120);
9052 assert_eq!(
9053 decode_wire_value(&command["arguments"], JSON_CODEC).expect("child args"),
9054 json!([{"order_id": "order-42"}])
9055 );
9056 }
9057
9058 fn child_parent_worker() -> Worker {
9059 let client = Client::new("http://127.0.0.1:8080").expect("client");
9060 let mut worker = Worker::new(client, "rust-parent-workers");
9061 worker.register_workflow("rust.parent", |ctx, _input| async move {
9062 let child = ctx
9063 .start_child_workflow(
9064 "python.child",
9065 ChildWorkflowOptions::new("python-child-workers")
9066 .parent_close_policy(ParentClosePolicy::Terminate),
9067 json!([{"codec_probe": [1, true, "rust"]}]),
9068 )
9069 .await?;
9070 Ok(json!({
9071 "parent_workflow_id": child.parent.workflow_id,
9072 "parent_run_id": child.parent.run_id,
9073 "child_workflow_id": child.child.workflow_id,
9074 "child_run_id": child.child.run_id,
9075 "child_workflow_type": child.child_workflow_type,
9076 "result": child.result,
9077 }))
9078 });
9079 worker
9080 }
9081
9082 fn child_parent_task(event_type: &str, payload: Value) -> WorkflowTask {
9083 WorkflowTask {
9084 task_id: "wft-child-parent".to_string(),
9085 workflow_id: Some("wf-parent".to_string()),
9086 run_id: Some("run-parent".to_string()),
9087 workflow_type: "rust.parent".to_string(),
9088 payload_codec: JSON_CODEC.to_string(),
9089 arguments: Some(encode_value_envelope(&json!([]), JSON_CODEC).expect("input")),
9090 history_events: vec![
9091 HistoryEvent {
9092 event_type: "ChildWorkflowScheduled".to_string(),
9093 payload: json!({
9094 "sequence": 1,
9095 "child_call_id": "call-child",
9096 "child_workflow_instance_id": "wf-child",
9097 "child_workflow_run_id": "run-child",
9098 "child_workflow_type": "python.child",
9099 }),
9100 raw: HashMap::new(),
9101 },
9102 HistoryEvent {
9103 event_type: event_type.to_string(),
9104 payload,
9105 raw: HashMap::new(),
9106 },
9107 ],
9108 total_history_events: Some(2),
9109 history_size_bytes: None,
9110 continue_as_new_recommended: None,
9111 history_budget_pressure: None,
9112 next_history_page_token: None,
9113 workflow_task_attempt: 1,
9114 workflow_signal_id: None,
9115 signal_name: None,
9116 signal_arguments: None,
9117 lease_owner: Some("rust-worker".to_string()),
9118 }
9119 }
9120
9121 #[test]
9122 fn committed_child_result_replays_without_starting_a_duplicate() {
9123 let worker = child_parent_worker();
9124 let task = child_parent_task(
9125 "ChildRunCompleted",
9126 json!({
9127 "sequence": 1,
9128 "child_call_id": "call-child",
9129 "child_workflow_instance_id": "wf-child",
9130 "child_workflow_run_id": "run-child",
9131 "child_workflow_type": "python.child",
9132 "payload_codec": "json",
9133 "result": {"codec": "json", "blob": "{\"from\":\"python\",\"ok\":true}"},
9134 }),
9135 );
9136
9137 for _restart in 0..2 {
9138 let commands = worker
9139 .execute_workflow_task(task.clone())
9140 .expect("replayed parent task");
9141 assert_eq!(commands.len(), 1);
9142 assert_eq!(commands[0]["type"], "complete_workflow");
9143 assert!(!commands
9144 .iter()
9145 .any(|command| command["type"] == "start_child_workflow"));
9146 let output =
9147 decode_wire_value(&commands[0]["result"], JSON_CODEC).expect("parent output");
9148 assert_eq!(output["parent_workflow_id"], "wf-parent");
9149 assert_eq!(output["parent_run_id"], "run-parent");
9150 assert_eq!(output["child_workflow_id"], "wf-child");
9151 assert_eq!(output["child_run_id"], "run-child");
9152 assert_eq!(output["result"], json!({"from": "python", "ok": true}));
9153 }
9154 }
9155
9156 #[test]
9157 fn pending_child_replays_after_restart_without_starting_a_duplicate() {
9158 let worker = child_parent_worker();
9159 let mut task = child_parent_task("unused", Value::Null);
9160 task.history_events.truncate(1);
9161 task.total_history_events = Some(1);
9162
9163 for _redelivery_or_restart in 0..2 {
9164 let commands = worker
9165 .execute_workflow_task(task.clone())
9166 .expect("recorded child remains pending");
9167 assert!(
9168 commands.is_empty(),
9169 "recorded pending child must not be started again"
9170 );
9171 }
9172 }
9173
9174 #[test]
9175 fn child_cancellation_becomes_stable_parent_failure_command() {
9176 let worker = child_parent_worker();
9177 let task = child_parent_task(
9178 "ChildRunCancelled",
9179 json!({
9180 "sequence": 1,
9181 "child_workflow_instance_id": "wf-child",
9182 "child_workflow_run_id": "run-child",
9183 "child_workflow_type": "python.child",
9184 "failure_id": "failure-child",
9185 "failure_category": "cancelled",
9186 "message": "cancelled by parent-close policy",
9187 }),
9188 );
9189
9190 let commands = worker
9191 .execute_workflow_task(task)
9192 .expect("parent settlement");
9193 assert_eq!(commands.len(), 1);
9194 assert_eq!(commands[0]["type"], "fail_workflow");
9195 assert_eq!(commands[0]["exception_type"], "ChildWorkflowCancelled");
9196 assert_eq!(
9197 commands[0]["exception"]["properties"]["reason"],
9198 "cancelled"
9199 );
9200 assert_eq!(
9201 commands[0]["exception"]["properties"]["child_workflow_run_id"],
9202 "run-child"
9203 );
9204 }
9205
9206 #[test]
9207 fn workflow_can_handle_typed_child_failure() {
9208 let client = Client::new("http://127.0.0.1:8080").expect("client");
9209 let mut worker = Worker::new(client, "rust-parent-workers");
9210 worker.register_workflow("rust.handled-parent", |ctx, _input| async move {
9211 match ctx
9212 .start_child_workflow(
9213 "python.child",
9214 ChildWorkflowOptions::new("python-child-workers"),
9215 json!([]),
9216 )
9217 .await
9218 {
9219 Err(Error::ChildWorkflowFailed(failure)) => Ok(json!({
9220 "reason": failure.reason,
9221 "failure_id": failure.failure_id,
9222 "exception_class": failure.exception_class,
9223 "child_run_id": failure.child_workflow_run_id,
9224 })),
9225 Err(error) => Err(error),
9226 Ok(_) => Err(Error::WorkerLoop(
9227 "child unexpectedly succeeded".to_string(),
9228 )),
9229 }
9230 });
9231 let mut task = child_parent_task(
9232 "ChildRunFailed",
9233 json!({
9234 "sequence": 1,
9235 "child_workflow_instance_id": "wf-child",
9236 "child_workflow_run_id": "run-child",
9237 "child_workflow_type": "python.child",
9238 "failure_id": "failure-child",
9239 "failure_category": "child_workflow",
9240 "message": "payment rejected",
9241 "exception": {
9242 "type": "PaymentRejected",
9243 "class": "payments.PaymentRejected",
9244 "message": "payment rejected"
9245 }
9246 }),
9247 );
9248 task.workflow_type = "rust.handled-parent".to_string();
9249
9250 let commands = worker.execute_workflow_task(task).expect("handled failure");
9251 assert_eq!(commands[0]["type"], "complete_workflow");
9252 let output = decode_wire_value(&commands[0]["result"], JSON_CODEC).expect("parent output");
9253 assert_eq!(output["reason"], "child_workflow");
9254 assert_eq!(output["failure_id"], "failure-child");
9255 assert_eq!(output["exception_class"], "payments.PaymentRejected");
9256 assert_eq!(output["child_run_id"], "run-child");
9257 }
9258
9259 #[test]
9260 fn rust_hello_world_uses_signal_arguments_from_resume_payload() {
9261 let client = Client::new("http://127.0.0.1:8080").expect("client");
9262 let mut worker = Worker::new(client, "rust-workers");
9263
9264 worker.register_workflow("rust.hello_workflow", |ctx, _input| async move {
9265 let signal = ctx.wait_signal("start").await?;
9266 let name = signal
9267 .first()
9268 .and_then(|value| value.as_str())
9269 .unwrap_or("world");
9270 let greeting = ctx.activity("rust.hello_activity", json!([name])).await?;
9271 Ok(json!({
9272 "greeting": greeting,
9273 "language": "rust"
9274 }))
9275 });
9276
9277 let signal_arguments =
9278 encode_value_envelope(&json!(["Rust"]), DEFAULT_CODEC).expect("signal arguments");
9279 let task = WorkflowTask {
9280 task_id: "wft-rust-signal-1".to_string(),
9281 workflow_id: Some("wf-rust-hello".to_string()),
9282 run_id: Some("run-rust-hello".to_string()),
9283 workflow_type: "rust.hello_workflow".to_string(),
9284 payload_codec: DEFAULT_CODEC.to_string(),
9285 arguments: Some(encode_value_envelope(&json!([]), DEFAULT_CODEC).expect("input")),
9286 history_events: vec![HistoryEvent {
9287 event_type: "SignalReceived".to_string(),
9288 payload: json!({
9289 "signal_id": "sig-rust-1",
9290 "signal_name": "start"
9291 }),
9292 raw: HashMap::new(),
9293 }],
9294 total_history_events: Some(1),
9295 history_size_bytes: None,
9296 continue_as_new_recommended: None,
9297 history_budget_pressure: None,
9298 next_history_page_token: None,
9299 workflow_task_attempt: 1,
9300 workflow_signal_id: Some("sig-rust-1".to_string()),
9301 signal_name: Some("start".to_string()),
9302 signal_arguments: Some(signal_arguments),
9303 lease_owner: Some("rust-worker".to_string()),
9304 };
9305
9306 let commands = worker.execute_workflow_task(task).expect("workflow task");
9307
9308 assert_eq!(commands.len(), 1);
9309 assert_eq!(commands[0]["type"], "schedule_activity");
9310 assert_eq!(commands[0]["activity_type"], "rust.hello_activity");
9311 assert_eq!(
9312 decode_wire_value(&commands[0]["arguments"], DEFAULT_CODEC).expect("activity args"),
9313 json!(["Rust"])
9314 );
9315 }
9316
9317 #[test]
9318 fn workflow_task_appends_paginated_history_events() {
9319 let mut task = WorkflowTask {
9320 task_id: "wft-rust-pages-1".to_string(),
9321 workflow_id: Some("wf-rust-pages".to_string()),
9322 run_id: Some("run-rust-pages".to_string()),
9323 workflow_type: "rust.hello_workflow".to_string(),
9324 payload_codec: DEFAULT_CODEC.to_string(),
9325 arguments: Some(encode_value_envelope(&json!([]), DEFAULT_CODEC).expect("input")),
9326 history_events: vec![HistoryEvent {
9327 event_type: "WorkflowStarted".to_string(),
9328 payload: json!({}),
9329 raw: HashMap::new(),
9330 }],
9331 total_history_events: Some(3),
9332 history_size_bytes: None,
9333 continue_as_new_recommended: None,
9334 history_budget_pressure: None,
9335 next_history_page_token: Some("MQ==".to_string()),
9336 workflow_task_attempt: 1,
9337 workflow_signal_id: None,
9338 signal_name: None,
9339 signal_arguments: None,
9340 lease_owner: Some("rust-worker".to_string()),
9341 };
9342
9343 task.append_history_page(WorkflowTaskHistoryPage {
9344 history_events: vec![
9345 HistoryEvent {
9346 event_type: "SignalReceived".to_string(),
9347 payload: json!({
9348 "signal_id": "sig-rust-1",
9349 "signal_name": "start",
9350 "arguments": encode_value_envelope(&json!(["Rust"]), DEFAULT_CODEC)
9351 .expect("signal arguments")
9352 }),
9353 raw: HashMap::new(),
9354 },
9355 HistoryEvent {
9356 event_type: "MarkerRecorded".to_string(),
9357 payload: json!({"sequence": 3}),
9358 raw: HashMap::new(),
9359 },
9360 ],
9361 total_history_events: Some(3),
9362 next_history_page_token: None,
9363 });
9364
9365 assert_eq!(task.history_events.len(), 3);
9366 assert_eq!(task.total_history_events, Some(3));
9367 assert_eq!(task.next_history_page_token, None);
9368
9369 let signals =
9370 signal_values(&task.history_events, "start", DEFAULT_CODEC, None).expect("signals");
9371 assert_eq!(signals, vec![vec![json!("Rust")]]);
9372 }
9373
9374 #[tokio::test]
9375 async fn query_handler_reads_ordered_cross_codec_signals_without_commands() {
9376 let client = Client::new("http://127.0.0.1:8080").expect("client");
9377 let mut worker = Worker::new(client, "rust-workers");
9378 worker.register_workflow("counter", |_ctx, _input| async move { Ok(Value::Null) });
9379 worker.register_query("counter", "current", |ctx, _args| async move {
9380 let mut count = 0_i64;
9381 for signal in ctx.signal_events() {
9382 let value = signal
9383 .arguments
9384 .first()
9385 .and_then(Value::as_i64)
9386 .unwrap_or_default();
9387 match signal.name.as_str() {
9388 "increment" => count += value,
9389 "set" => count = value,
9390 _ => {}
9391 }
9392 }
9393 Ok(json!(count))
9394 });
9395
9396 let task = QueryTask {
9397 query_task_id: "query-rust-counter".to_string(),
9398 query_task_attempt: 1,
9399 lease_owner: Some("rust-worker".to_string()),
9400 workflow_id: Some("counter-1".to_string()),
9401 run_id: Some("run-counter-1".to_string()),
9402 workflow_type: "counter".to_string(),
9403 query_name: "current".to_string(),
9404 payload_codec: DEFAULT_CODEC.to_string(),
9405 workflow_arguments: Some(
9406 encode_value_envelope(&json!([]), DEFAULT_CODEC).expect("workflow input"),
9407 ),
9408 query_arguments: Some(
9409 encode_value_envelope(&json!([]), DEFAULT_CODEC).expect("query arguments"),
9410 ),
9411 history_events: vec![
9412 HistoryEvent {
9413 event_type: "SignalReceived".to_string(),
9414 payload: json!({
9415 "signal_id": "php-signal-1",
9416 "signal_name": "increment",
9417 "workflow_sequence": 1,
9418 "payload_codec": DEFAULT_CODEC,
9419 "arguments": encode_value_envelope(&json!([3]), DEFAULT_CODEC).expect("php avro signal")
9420 }),
9421 raw: HashMap::new(),
9422 },
9423 HistoryEvent {
9424 event_type: "SignalReceived".to_string(),
9425 payload: json!({
9426 "signal_id": "python-signal-2",
9427 "signal_name": "increment",
9428 "workflow_sequence": 2,
9429 "payload_codec": JSON_CODEC,
9430 "arguments": encode_value_envelope(&json!([5]), JSON_CODEC).expect("python json signal")
9431 }),
9432 raw: HashMap::new(),
9433 },
9434 HistoryEvent {
9435 event_type: "SignalReceived".to_string(),
9436 payload: json!({
9437 "signal_id": "rust-signal-3",
9438 "signal_name": "set",
9439 "workflow_sequence": 3,
9440 "payload_codec": DEFAULT_CODEC,
9441 "arguments": encode_value_envelope(&json!([0]), DEFAULT_CODEC).expect("rust avro signal")
9442 }),
9443 raw: HashMap::new(),
9444 },
9445 ],
9446 history_export: None,
9447 run_status: Some("completed".to_string()),
9448 };
9449
9450 let result = worker.execute_query_task(task).await.expect("query result");
9451 assert_eq!(result, json!(0));
9452 }
9453
9454 #[tokio::test]
9455 async fn replayed_queries_read_running_completed_and_cold_restarted_instance_state() {
9456 let worker = replay_counter_worker();
9457 let running_history = json!([
9458 {
9459 "type": "ActivityCompleted",
9460 "payload": {
9461 "sequence": 1,
9462 "activity_type": "load-counter",
9463 "payload_codec": "json",
9464 "result": {"codec": "json", "blob": "\"loaded\""}
9465 }
9466 },
9467 {
9468 "type": "SignalReceived",
9469 "payload": {
9470 "signal_id": "signal-3",
9471 "signal_name": "increment",
9472 "payload_codec": "json",
9473 "arguments": {"codec": "json", "blob": "[3]"}
9474 }
9475 }
9476 ]);
9477
9478 let running = worker
9479 .execute_query_task(replay_counter_query(
9480 "current",
9481 running_history.clone(),
9482 "running",
9483 ))
9484 .await
9485 .expect("running replay query");
9486 assert_eq!(
9487 running,
9488 json!({"loaded": "loaded", "count": 3, "finished": false})
9489 );
9490
9491 let detached = worker
9492 .execute_query_task(replay_counter_query(
9493 "detached-mutation",
9494 running_history.clone(),
9495 "running",
9496 ))
9497 .await
9498 .expect("query mutates only its detached state clone");
9499 assert_eq!(detached, json!(999));
9500 let failed = worker
9501 .execute_query_task(replay_counter_query(
9502 "failed-mutation",
9503 running_history.clone(),
9504 "running",
9505 ))
9506 .await
9507 .expect_err("failed query");
9508 assert_eq!(failed.reason, "query_rejected");
9509 let unchanged = worker
9510 .execute_query_task(replay_counter_query("current", running_history, "running"))
9511 .await
9512 .expect("later query reconstructs unchanged state");
9513 assert_eq!(unchanged, running);
9514
9515 let restarted_worker = replay_counter_worker();
9516 let restarted_task: QueryTask = serde_json::from_value(json!({
9517 "query_task_id": "query-after-restart",
9518 "workflow_id": "counter-1",
9519 "run_id": "run-counter-1",
9520 "workflow_type": "replay-counter",
9521 "query_name": "current",
9522 "payload_codec": "json",
9523 "workflow_arguments": {"codec": "json", "blob": "[]"},
9524 "query_arguments": {"codec": "json", "blob": "[]"},
9525 "history_events": [],
9526 "history_export": {
9527 "payloads": {"codec": "json"},
9528 "history_events": [
9529 {
9530 "type": "ActivityCompleted",
9531 "payload": {
9532 "sequence": 1,
9533 "activity_type": "load-counter",
9534 "payload_codec": "json",
9535 "result": null
9536 }
9537 },
9538 {
9539 "type": "SignalReceived",
9540 "payload": {"signal_id": "signal-3", "signal_name": "increment"}
9541 },
9542 {
9543 "type": "SignalReceived",
9544 "payload": {"signal_id": "signal-5", "signal_name": "increment"}
9545 }
9546 ],
9547 "activities": [{
9548 "sequence": 1,
9549 "activity_type": "load-counter",
9550 "payload_codec": "json",
9551 "result": {"codec": "json", "blob": "\"loaded\""}
9552 }],
9553 "signals": [
9554 {
9555 "id": "signal-3",
9556 "name": "increment",
9557 "payload_codec": "json",
9558 "arguments": "[3]"
9559 },
9560 {
9561 "id": "signal-5",
9562 "name": "increment",
9563 "payload_codec": "json",
9564 "arguments": "[5]"
9565 }
9566 ]
9567 },
9568 "run_status": "completed"
9569 }))
9570 .expect("cold replay query task");
9571 let completed = restarted_worker
9572 .execute_query_task(restarted_task)
9573 .await
9574 .expect("completed cold replay query");
9575 assert_eq!(
9576 completed,
9577 json!({"loaded": "loaded", "count": 8, "finished": true})
9578 );
9579 }
9580
9581 #[tokio::test]
9582 async fn replayed_query_replay_failures_are_machine_readable() {
9583 let worker = replay_counter_worker();
9584 let task = replay_counter_query(
9585 "current",
9586 json!([{
9587 "type": "ActivityCompleted",
9588 "payload": {
9589 "sequence": 1,
9590 "payload_codec": "json",
9591 "result": {"codec": "json", "blob": "{"}
9592 }
9593 }]),
9594 "running",
9595 );
9596 let failure = worker
9597 .execute_query_task(task)
9598 .await
9599 .expect_err("invalid replay history payload");
9600 assert_eq!(failure.reason, "query_workflow_state_unavailable");
9601 assert_eq!(failure.failure_type, "QueryWorkflowStateUnavailable");
9602 }
9603
9604 #[tokio::test]
9605 async fn query_task_restores_compact_history_from_export() {
9606 let client = Client::new("http://127.0.0.1:8080").expect("client");
9607 let mut worker = Worker::new(client, "rust-workers");
9608 worker.register_workflow("counter", |_ctx, _input| async move { Ok(Value::Null) });
9609 worker.register_query("counter", "current", |ctx, _args| async move {
9610 Ok(json!(ctx.signals("increment")[0][0]))
9611 });
9612 let task: QueryTask = serde_json::from_value(json!({
9613 "query_task_id": "query-export",
9614 "workflow_type": "counter",
9615 "query_name": "current",
9616 "payload_codec": "json",
9617 "workflow_arguments": {"codec": "json", "blob": "[]"},
9618 "query_arguments": {"codec": "json", "blob": "[]"},
9619 "history_events": [],
9620 "history_export": {
9621 "payloads": {"codec": "json"},
9622 "history_events": [{
9623 "type": "SignalReceived",
9624 "payload": {"signal_id": "signal-export", "signal_name": "increment"}
9625 }],
9626 "signals": [{
9627 "id": "signal-export",
9628 "name": "increment",
9629 "status": "applied",
9630 "workflow_sequence": 1,
9631 "payload_codec": "json",
9632 "arguments": "[9]"
9633 }]
9634 }
9635 }))
9636 .expect("query task");
9637
9638 let result = worker.execute_query_task(task).await.expect("query result");
9639 assert_eq!(result, json!(9));
9640 }
9641
9642 #[tokio::test]
9643 async fn query_task_failures_have_stable_reasons() {
9644 let client = Client::new("http://127.0.0.1:8080").expect("client");
9645 let mut worker = Worker::new(client, "rust-workers");
9646 worker.register_workflow("counter", |_ctx, _input| async move { Ok(Value::Null) });
9647 worker.register_query(
9648 "counter",
9649 "current",
9650 |_ctx, _args| async move { Ok(json!(0)) },
9651 );
9652
9653 let base_task = QueryTask {
9654 query_task_id: "query-errors".to_string(),
9655 query_task_attempt: 1,
9656 lease_owner: None,
9657 workflow_id: Some("counter-errors".to_string()),
9658 run_id: Some("run-errors".to_string()),
9659 workflow_type: "counter".to_string(),
9660 query_name: "missing".to_string(),
9661 payload_codec: JSON_CODEC.to_string(),
9662 workflow_arguments: Some(json!({"codec": "json", "blob": "[]"})),
9663 query_arguments: Some(json!({"codec": "json", "blob": "[]"})),
9664 history_events: Vec::new(),
9665 history_export: None,
9666 run_status: Some("running".to_string()),
9667 };
9668
9669 let unknown = worker
9670 .execute_query_task(base_task.clone())
9671 .await
9672 .expect_err("unknown query");
9673 assert_eq!(unknown.reason, "rejected_unknown_query");
9674
9675 let mut malformed = base_task;
9676 malformed.query_name = "current".to_string();
9677 malformed.query_arguments = Some(json!({"codec": "json", "blob": "{"}));
9678 let malformed = worker
9679 .execute_query_task(malformed)
9680 .await
9681 .expect_err("malformed payload");
9682 assert_eq!(malformed.reason, "query_payload_decode_failed");
9683
9684 let client = Client::new("http://127.0.0.1:8080").expect("client");
9685 let mut unavailable_worker = Worker::new(client, "rust-workers");
9686 unavailable_worker
9687 .register_workflow("counter", |_ctx, _input| async move { Ok(Value::Null) });
9688 let unavailable_task: QueryTask = serde_json::from_value(json!({
9689 "query_task_id": "query-unavailable",
9690 "workflow_type": "counter",
9691 "query_name": "current",
9692 "payload_codec": "json",
9693 "workflow_arguments": {"codec": "json", "blob": "[]"},
9694 "query_arguments": {"codec": "json", "blob": "[]"}
9695 }))
9696 .expect("query task");
9697 let unavailable = unavailable_worker
9698 .execute_query_task(unavailable_task)
9699 .await
9700 .expect_err("query handler unavailable");
9701 assert_eq!(unavailable.reason, "query_handler_unavailable");
9702 }
9703
9704 #[tokio::test]
9705 async fn client_query_decodes_result_and_typed_failure() {
9706 let server = MockWorkerServer::start();
9707 let client = Client::builder(server.base_url())
9708 .timeout(Duration::from_secs(2))
9709 .build()
9710 .expect("client");
9711
9712 let result = client
9713 .query_workflow("counter-1", "current", json!([]))
9714 .await
9715 .expect("query result");
9716 assert_eq!(result, json!({"count": 8}));
9717
9718 let error = client
9719 .query_workflow("counter-1", "missing", json!([]))
9720 .await
9721 .expect_err("unknown query");
9722 let Error::QueryFailed(failure) = error else {
9723 panic!("expected typed query failure");
9724 };
9725 assert_eq!(failure.status, 404);
9726 assert_eq!(failure.reason, "rejected_unknown_query");
9727 }
9728
9729 #[tokio::test]
9730 async fn lifecycle_commands_support_instance_and_selected_run_targets() {
9731 let server = MockWorkerServer::start();
9732 let client = Client::builder(server.base_url())
9733 .timeout(Duration::from_secs(2))
9734 .build()
9735 .expect("client");
9736
9737 let options = WorkflowCommandOptions::new()
9738 .reason("cleanup requested")
9739 .request_id("cancel-17");
9740 let cancelled = client
9741 .cancel_workflow("wf-lifecycle", options)
9742 .await
9743 .expect("instance cancellation");
9744 assert_eq!(cancelled.command, WorkflowCommandKind::Cancel);
9745 assert_eq!(cancelled.run_id.as_deref(), Some("run-current"));
9746 assert_eq!(cancelled.outcome.as_deref(), Some("cancelled"));
9747 assert_eq!(
9748 server.request_body("/api/workflows/wf-lifecycle/cancel"),
9749 json!({"reason":"cleanup requested","request_id":"cancel-17"})
9750 );
9751
9752 let terminated = client
9753 .terminate_workflow(
9754 "wf-lifecycle",
9755 WorkflowCommandOptions::new().reason("forced stop"),
9756 )
9757 .await
9758 .expect("instance termination");
9759 assert_eq!(terminated.command, WorkflowCommandKind::Terminate);
9760 assert_eq!(terminated.outcome.as_deref(), Some("terminated"));
9761
9762 client
9763 .cancel_workflow_run(
9764 "wf-lifecycle",
9765 "run-current",
9766 WorkflowCommandOptions::default(),
9767 )
9768 .await
9769 .expect("selected run cancellation");
9770 client
9771 .terminate_workflow_run(
9772 "wf-lifecycle",
9773 "run-current",
9774 WorkflowCommandOptions::default(),
9775 )
9776 .await
9777 .expect("selected run termination");
9778
9779 for (command, error) in [
9780 (
9781 WorkflowCommandKind::Cancel,
9782 client
9783 .cancel_workflow_run(
9784 "wf-lifecycle",
9785 "run-stale",
9786 WorkflowCommandOptions::default(),
9787 )
9788 .await
9789 .expect_err("stale cancellation must be rejected"),
9790 ),
9791 (
9792 WorkflowCommandKind::Terminate,
9793 client
9794 .terminate_workflow_run(
9795 "wf-lifecycle",
9796 "run-stale",
9797 WorkflowCommandOptions::default(),
9798 )
9799 .await
9800 .expect_err("stale termination must be rejected"),
9801 ),
9802 ] {
9803 let Error::WorkflowCommandRejected(rejection) = error else {
9804 panic!("expected typed command rejection");
9805 };
9806 assert_eq!(rejection.command, command);
9807 assert_eq!(rejection.status, 409);
9808 assert_eq!(rejection.reason, "historical_run_command_rejected");
9809 assert_eq!(rejection.run_id.as_deref(), Some("run-stale"));
9810 assert_eq!(rejection.target_scope.as_deref(), Some("run"));
9811 }
9812 }
9813
9814 #[tokio::test]
9815 async fn workflow_start_options_send_server_enforced_deadlines() {
9816 let server = MockWorkerServer::start();
9817 let client = Client::builder(server.base_url())
9818 .timeout(Duration::from_secs(2))
9819 .build()
9820 .expect("client");
9821
9822 let handle = client
9823 .start_workflow_with_options(
9824 "rust.timeout",
9825 "rust-timeouts",
9826 "wf-start-options",
9827 WorkflowStartOptions::new()
9828 .execution_timeout_seconds(30)
9829 .run_timeout_seconds(1),
9830 json!([]),
9831 )
9832 .await
9833 .expect("workflow start");
9834
9835 assert_eq!(handle.run_id.as_deref(), Some("run-start-options"));
9836 let body = server.request_body("/api/workflows");
9837 assert_eq!(body["execution_timeout_seconds"], 30);
9838 assert_eq!(body["run_timeout_seconds"], 1);
9839
9840 let invalid = client
9841 .start_workflow_with_options(
9842 "rust.timeout",
9843 "rust-timeouts",
9844 "wf-invalid-options",
9845 WorkflowStartOptions::new()
9846 .execution_timeout_seconds(1)
9847 .run_timeout_seconds(2),
9848 json!([]),
9849 )
9850 .await
9851 .expect_err("invalid deadline ordering");
9852 assert!(invalid
9853 .to_string()
9854 .contains("run_timeout_seconds cannot exceed execution_timeout_seconds"));
9855 }
9856
9857 #[tokio::test]
9858 async fn workflow_result_returns_each_typed_terminal_outcome() {
9859 let server = MockWorkerServer::start();
9860 let client = Client::builder(server.base_url())
9861 .timeout(Duration::from_secs(2))
9862 .build()
9863 .expect("client");
9864 let options = WorkflowResultOptions {
9865 poll_interval: Duration::ZERO,
9866 timeout: Duration::from_secs(1),
9867 };
9868
9869 let failed = WorkflowHandle {
9870 client: client.clone(),
9871 workflow_id: "wf-failed".to_string(),
9872 run_id: Some("run-failed".to_string()),
9873 workflow_type: "failure".to_string(),
9874 }
9875 .result(options)
9876 .await
9877 .expect_err("failed outcome");
9878 let Error::WorkflowFailed(failure) = failed else {
9879 panic!("expected WorkflowFailed");
9880 };
9881 assert_eq!(failure.workflow_id, "wf-failed");
9882 assert_eq!(failure.run_id.as_deref(), Some("run-failed"));
9883 assert_eq!(failure.failure_id.as_deref(), Some("failure-17"));
9884 assert_eq!(failure.failure_category.as_deref(), Some("application"));
9885 assert_eq!(failure.exception_type.as_deref(), Some("PaymentError"));
9886 assert_eq!(
9887 failure.exception_class.as_deref(),
9888 Some("billing::PaymentError")
9889 );
9890 assert_eq!(failure.non_retryable, Some(true));
9891
9892 for (workflow_id, expected_kind, expected_reason) in [
9893 (
9894 "wf-cancelled",
9895 WorkflowTerminalKind::Cancelled,
9896 "cleanup requested",
9897 ),
9898 (
9899 "wf-terminated",
9900 WorkflowTerminalKind::Terminated,
9901 "forced stop",
9902 ),
9903 (
9904 "wf-timed-out",
9905 WorkflowTerminalKind::TimedOut,
9906 "run_timeout",
9907 ),
9908 ] {
9909 let error = WorkflowHandle {
9910 client: client.clone(),
9911 workflow_id: workflow_id.to_string(),
9912 run_id: None,
9913 workflow_type: "terminal".to_string(),
9914 }
9915 .result(options)
9916 .await
9917 .expect_err("typed terminal outcome");
9918 let outcome = match error {
9919 Error::WorkflowCancelled(outcome) => outcome,
9920 Error::WorkflowTerminated(outcome) => outcome,
9921 Error::WorkflowTimedOut(outcome) => outcome,
9922 other => panic!("unexpected terminal error: {other}"),
9923 };
9924 assert_eq!(outcome.kind, expected_kind);
9925 assert_eq!(outcome.workflow_id, workflow_id);
9926 assert_eq!(outcome.reason, expected_reason);
9927 }
9928
9929 let wait_timeout = WorkflowHandle {
9930 client,
9931 workflow_id: "wf-waiting".to_string(),
9932 run_id: Some("run-waiting".to_string()),
9933 workflow_type: "waiting".to_string(),
9934 }
9935 .result(WorkflowResultOptions {
9936 poll_interval: Duration::ZERO,
9937 timeout: Duration::ZERO,
9938 })
9939 .await
9940 .expect_err("client wait timeout");
9941 let Error::WorkflowTimedOut(timeout) = wait_timeout else {
9942 panic!("expected typed client timeout");
9943 };
9944 assert_eq!(timeout.reason, "result_wait_timeout");
9945 assert_eq!(timeout.failure_category.as_deref(), Some("client_timeout"));
9946 assert_eq!(timeout.run_id.as_deref(), Some("run-waiting"));
9947 }
9948
9949 #[tokio::test]
9950 async fn workflow_result_follows_chain_and_selected_result_preserves_history() {
9951 let server = MockWorkerServer::start();
9952 let client = Client::builder(server.base_url())
9953 .timeout(Duration::from_secs(2))
9954 .build()
9955 .expect("client");
9956
9957 let handle = WorkflowHandle {
9958 client,
9959 workflow_id: "wf-selected".to_string(),
9960 run_id: Some("run-selected".to_string()),
9961 workflow_type: "selected".to_string(),
9962 };
9963 let options = WorkflowResultOptions {
9964 poll_interval: Duration::ZERO,
9965 timeout: Duration::from_secs(1),
9966 };
9967
9968 let current = handle
9969 .result(options)
9970 .await
9971 .expect("instance result follows the current run");
9972 assert_eq!(current, json!("current run output"));
9973
9974 let error = handle
9975 .result_selected_run(options)
9976 .await
9977 .expect_err("the selected run is cancelled even though the current run completed");
9978
9979 let Error::WorkflowCancelled(outcome) = error else {
9980 panic!("expected selected run cancellation");
9981 };
9982 assert_eq!(outcome.run_id.as_deref(), Some("run-selected"));
9983 assert_eq!(outcome.reason, "selected run cancelled");
9984 assert_eq!(
9985 server.request_count("/api/workflows/wf-selected/runs/run-selected"),
9986 1
9987 );
9988 assert_eq!(server.request_count("/api/workflows/wf-selected"), 1);
9989 }
9990
9991 #[tokio::test]
9992 async fn poll_responses_decode_http_conflict_drain_as_a_stable_stop() {
9993 let server = MockWorkerServer::draining_polls();
9994 let client = Client::builder(server.base_url())
9995 .timeout(Duration::from_secs(2))
9996 .build()
9997 .expect("client");
9998
9999 let workflow = client
10000 .poll_workflow_task_response("draining-worker", "rust-workers", Duration::ZERO)
10001 .await
10002 .expect("workflow drain response");
10003 let activity = client
10004 .poll_activity_task_response("draining-worker", "rust-workers", Duration::ZERO)
10005 .await
10006 .expect("activity drain response");
10007 let query = client
10008 .poll_query_task_response("draining-worker", "rust-workers", Duration::ZERO)
10009 .await
10010 .expect("query drain response");
10011
10012 for outcome in [workflow.outcome(), activity.outcome(), query.outcome()] {
10013 assert_eq!(
10014 outcome,
10015 WorkerPollOutcome::Stop {
10016 poll_status: Some("draining".to_string()),
10017 reason: Some("worker_draining".to_string()),
10018 }
10019 );
10020 }
10021
10022 assert!(client
10023 .poll_workflow_task("draining-worker", "rust-workers", Duration::ZERO)
10024 .await
10025 .expect("compatibility poll")
10026 .is_none());
10027 }
10028
10029 #[tokio::test]
10030 async fn managed_worker_honors_drain_stop_for_every_task_family() {
10031 let server = MockWorkerServer::draining_polls();
10032 let client = Client::builder(server.base_url())
10033 .timeout(Duration::from_secs(2))
10034 .build()
10035 .expect("client");
10036
10037 let mut workflow_worker = Worker::new(client.clone(), "rust-workers")
10038 .worker_id("draining-workflow-worker")
10039 .poll_timeout(Duration::ZERO);
10040 workflow_worker.register_workflow("counter", |_ctx, _args| async { Ok(Value::Null) });
10041 workflow_worker
10042 .run()
10043 .await
10044 .expect("workflow drain is a clean stop");
10045
10046 let mut activity_worker = Worker::new(client.clone(), "rust-workers")
10047 .worker_id("draining-activity-worker")
10048 .poll_timeout(Duration::ZERO);
10049 activity_worker.register_activity("write", |_ctx, _args| async { Ok(Value::Null) });
10050 activity_worker
10051 .run()
10052 .await
10053 .expect("activity drain is a clean stop");
10054
10055 let mut query_worker = Worker::new(client, "rust-workers")
10056 .worker_id("draining-query-worker")
10057 .poll_timeout(Duration::ZERO);
10058 query_worker.register_query("counter", "current", |_ctx, _args| async {
10059 Ok(Value::Null)
10060 });
10061 query_worker
10062 .run()
10063 .await
10064 .expect("query drain is a clean stop");
10065 }
10066
10067 #[tokio::test]
10068 async fn activity_cancellation_and_late_completion_remain_machine_readable() {
10069 let server = MockWorkerServer::start();
10070 let client = Client::builder(server.base_url())
10071 .timeout(Duration::from_secs(2))
10072 .build()
10073 .expect("client");
10074
10075 let heartbeat = client
10076 .heartbeat_activity_task(
10077 "activity-cancel",
10078 "attempt-cancel",
10079 "rust-worker",
10080 json!({"stage":"cleanup"}),
10081 )
10082 .await
10083 .expect("cancellation heartbeat");
10084 assert!(heartbeat.cancel_requested);
10085 assert!(heartbeat.should_stop());
10086 assert_eq!(heartbeat.reason.as_deref(), Some("run_cancelled"));
10087 assert_eq!(heartbeat.run_closed_reason.as_deref(), Some("cancelled"));
10088
10089 let error = client
10090 .complete_activity_task(
10091 "activity-cancel",
10092 "attempt-cancel",
10093 "rust-worker",
10094 json!({"late":true}),
10095 JSON_CODEC,
10096 )
10097 .await
10098 .expect_err("late completion must be refused");
10099 assert!(activity_task_rejection_is_final(&error));
10100 let Error::ActivityTaskRejected(rejection) = error else {
10101 panic!("expected typed activity rejection");
10102 };
10103 assert_eq!(rejection.status, 409);
10104 assert_eq!(rejection.reason, "run_cancelled");
10105 assert!(rejection.cancel_requested);
10106 assert_eq!(rejection.can_continue, Some(false));
10107 }
10108
10109 #[tokio::test]
10110 async fn managed_worker_survives_late_completion_and_restart_during_cancellation() {
10111 let server = MockWorkerServer::cancelled_activity();
10112 let client = Client::builder(server.base_url())
10113 .timeout(Duration::from_secs(2))
10114 .build()
10115 .expect("client");
10116 let cancellation_observed = Arc::new(AtomicBool::new(false));
10117 let observed = Arc::clone(&cancellation_observed);
10118 let mut worker = Worker::new(client.clone(), "rust-workers")
10119 .worker_id("rust-cancel-worker")
10120 .poll_timeout(Duration::from_millis(10));
10121 worker.register_activity("cancel-aware", move |ctx, _args| {
10122 let observed = Arc::clone(&observed);
10123 async move {
10124 let heartbeat = ctx.heartbeat(json!({"stage":"running"})).await?;
10125 observed.store(heartbeat.should_stop(), Ordering::SeqCst);
10126 Ok(json!({"late":"completion"}))
10127 }
10128 });
10129
10130 assert_eq!(
10131 worker.run_once().await.expect("cancelled attempt handled"),
10132 1
10133 );
10134 assert!(cancellation_observed.load(Ordering::SeqCst));
10135 assert_eq!(
10136 server.request_count("/api/worker/activity-tasks/activity-cancel/complete"),
10137 1
10138 );
10139
10140 let mut restarted = Worker::new(client, "rust-workers")
10141 .worker_id("rust-cancel-worker-restarted")
10142 .poll_timeout(Duration::from_millis(10));
10143 restarted.register_activity("cancel-aware", |_ctx, _args| async move { Ok(Value::Null) });
10144 assert_eq!(
10145 restarted
10146 .run_once()
10147 .await
10148 .expect("replacement worker continues polling"),
10149 0
10150 );
10151 }
10152
10153 #[tokio::test]
10154 async fn managed_worker_absorbs_selected_run_terminal_timeout_completion_race() {
10155 let response = r#"{"task_id":"workflow-timeout-task","workflow_task_attempt":3,"outcome":"completed","recorded":false,"run_id":"run-selected-timeout","run_status":"failed","created_task_ids":[],"reason":"run_timed_out"}"#;
10156 let server = MockWorkerServer::workflow_completion("409 Conflict", response);
10157 let client = Client::builder(server.base_url())
10158 .timeout(Duration::from_secs(2))
10159 .build()
10160 .expect("client");
10161
10162 let direct_error = client
10163 .complete_workflow_task(
10164 "workflow-timeout-task",
10165 "timeout-worker",
10166 3,
10167 vec![json!({"type": "complete_workflow", "result": null})],
10168 )
10169 .await
10170 .expect_err("the low-level client preserves the completion rejection");
10171 let Error::Http { status, body } = direct_error else {
10172 panic!("expected the original HTTP completion rejection");
10173 };
10174 assert_eq!(status, reqwest::StatusCode::CONFLICT);
10175 assert_eq!(
10176 serde_json::from_str::<Value>(&body).expect("response body")["reason"],
10177 "run_timed_out"
10178 );
10179
10180 let mut worker = Worker::new(client, "rust-workers")
10181 .worker_id("timeout-worker")
10182 .poll_timeout(Duration::from_millis(10));
10183 worker.register_workflow("timeout.workflow", |_ctx, _input| async move {
10184 Ok(json!({"late": "result"}))
10185 });
10186
10187 assert_eq!(
10188 worker
10189 .run_once()
10190 .await
10191 .expect("authoritative selected-run timeout settles the tick"),
10192 1
10193 );
10194 assert_eq!(
10195 server.request_count("/api/worker/workflow-tasks/workflow-timeout-task/complete"),
10196 2,
10197 "both the direct client proof and managed worker must see the rejection"
10198 );
10199 }
10200
10201 #[tokio::test]
10202 async fn managed_worker_does_not_swallow_nearby_completion_errors() {
10203 for (name, status, response) in [
10204 ("bare conflict", "409 Conflict", r#"{"message":"conflict"}"#),
10205 (
10206 "command was recorded",
10207 "409 Conflict",
10208 r#"{"task_id":"workflow-timeout-task","workflow_task_attempt":3,"recorded":true,"run_id":"run-selected-timeout","run_status":"failed","reason":"run_timed_out"}"#,
10209 ),
10210 (
10211 "lease conflict",
10212 "409 Conflict",
10213 r#"{"task_id":"workflow-timeout-task","workflow_task_attempt":3,"recorded":false,"run_id":"run-selected-timeout","run_status":"failed","reason":"lease_expired"}"#,
10214 ),
10215 (
10216 "nonterminal run",
10217 "409 Conflict",
10218 r#"{"task_id":"workflow-timeout-task","workflow_task_attempt":3,"recorded":false,"run_id":"run-selected-timeout","run_status":"waiting","reason":"run_timed_out"}"#,
10219 ),
10220 (
10221 "different selected run",
10222 "409 Conflict",
10223 r#"{"task_id":"workflow-timeout-task","workflow_task_attempt":3,"recorded":false,"run_id":"run-reused-workflow-current","run_status":"failed","reason":"run_timed_out"}"#,
10224 ),
10225 (
10226 "different task attempt",
10227 "409 Conflict",
10228 r#"{"task_id":"workflow-timeout-task","workflow_task_attempt":4,"recorded":false,"run_id":"run-selected-timeout","run_status":"failed","reason":"run_timed_out"}"#,
10229 ),
10230 (
10231 "authentication failure",
10232 "401 Unauthorized",
10233 r#"{"task_id":"workflow-timeout-task","workflow_task_attempt":3,"recorded":false,"run_id":"run-selected-timeout","run_status":"failed","reason":"run_timed_out"}"#,
10234 ),
10235 (
10236 "authorization failure",
10237 "403 Forbidden",
10238 r#"{"task_id":"workflow-timeout-task","workflow_task_attempt":3,"recorded":false,"run_id":"run-selected-timeout","run_status":"failed","reason":"run_timed_out"}"#,
10239 ),
10240 (
10241 "protocol failure",
10242 "400 Bad Request",
10243 r#"{"reason":"unsupported_protocol_version","message":"unsupported worker protocol","supported_version":"1.2","requested_version":"1.3"}"#,
10244 ),
10245 (
10246 "malformed command",
10247 "422 Unprocessable Entity",
10248 r#"{"task_id":"workflow-timeout-task","workflow_task_attempt":3,"recorded":false,"run_id":"run-selected-timeout","run_status":"failed","reason":"run_timed_out"}"#,
10249 ),
10250 (
10251 "transient server failure",
10252 "503 Service Unavailable",
10253 r#"{"task_id":"workflow-timeout-task","workflow_task_attempt":3,"recorded":false,"run_id":"run-selected-timeout","run_status":"failed","reason":"run_timed_out"}"#,
10254 ),
10255 ] {
10256 let server = MockWorkerServer::workflow_completion(status, response);
10257 let client = Client::builder(server.base_url())
10258 .timeout(Duration::from_secs(2))
10259 .build()
10260 .expect("client");
10261 let mut worker = Worker::new(client, "rust-workers")
10262 .worker_id("timeout-worker")
10263 .poll_timeout(Duration::from_millis(10));
10264 worker.register_workflow("timeout.workflow", |_ctx, _input| async move {
10265 Ok(json!({"late": "result"}))
10266 });
10267
10268 let error = worker
10269 .run_once()
10270 .await
10271 .expect_err(&format!("{name} must remain an error"));
10272 assert!(
10273 matches!(error, Error::Http { .. } | Error::Protocol(_)),
10274 "{name} returned an unexpected error variant: {error}"
10275 );
10276 }
10277 }
10278
10279 #[tokio::test]
10280 async fn baseline_worker_endpoints_send_the_baseline_protocol() {
10281 let server = MockWorkerServer::start();
10282 let client = Client::builder(server.base_url())
10283 .timeout(Duration::from_secs(2))
10284 .build()
10285 .expect("client");
10286
10287 client
10288 .register_worker("capture-worker", "capture", vec![], vec![], 1, 1)
10289 .await
10290 .expect("register");
10291 client
10292 .heartbeat_worker("capture-worker", 1, 1)
10293 .await
10294 .expect("heartbeat");
10295 client
10296 .poll_workflow_task("capture-worker", "capture", Duration::from_millis(10))
10297 .await
10298 .expect("workflow poll");
10299 client
10300 .poll_activity_task("capture-worker", "capture", Duration::from_millis(10))
10301 .await
10302 .expect("activity poll");
10303
10304 for path in [
10305 "/api/worker/register",
10306 "/api/worker/heartbeat",
10307 "/api/worker/workflow-tasks/poll",
10308 "/api/worker/activity-tasks/poll",
10309 ] {
10310 assert_eq!(
10311 server.worker_protocol_for(path).as_deref(),
10312 Some(WORKER_PROTOCOL_VERSION),
10313 "unexpected protocol for {path}"
10314 );
10315 }
10316
10317 assert_eq!(
10318 server.request_body("/api/worker/workflow-tasks/poll")["timeout_seconds"],
10319 1
10320 );
10321 assert_eq!(
10322 server.request_body("/api/worker/activity-tasks/poll")["timeout_seconds"],
10323 1
10324 );
10325 }
10326
10327 #[tokio::test]
10328 async fn query_task_endpoints_send_the_query_feature_protocol() {
10329 let server = MockWorkerServer::start();
10330 let client = Client::builder(server.base_url())
10331 .timeout(Duration::from_secs(2))
10332 .build()
10333 .expect("client");
10334
10335 client
10336 .poll_query_task("capture-worker", "capture", Duration::from_millis(10))
10337 .await
10338 .expect("query poll");
10339 client
10340 .complete_query_task("query-capture", "capture-worker", 1, json!(8), JSON_CODEC)
10341 .await
10342 .expect("query complete");
10343 client
10344 .fail_query_task(
10345 "query-capture",
10346 "capture-worker",
10347 1,
10348 "failed",
10349 "query_rejected",
10350 "QueryFailed",
10351 )
10352 .await
10353 .expect("query fail");
10354
10355 for path in [
10356 "/api/worker/query-tasks/poll",
10357 "/api/worker/query-tasks/query-capture/complete",
10358 "/api/worker/query-tasks/query-capture/fail",
10359 ] {
10360 assert_eq!(
10361 server.worker_protocol_for(path).as_deref(),
10362 Some(QUERY_TASK_MINIMUM_WORKER_PROTOCOL_VERSION),
10363 "unexpected protocol for {path}"
10364 );
10365 }
10366
10367 assert_eq!(
10368 server.request_body("/api/worker/query-tasks/poll")["timeout_seconds"],
10369 1
10370 );
10371 }
10372
10373 #[tokio::test]
10374 async fn query_protocol_rejection_from_older_server_is_typed() {
10375 let server = MockWorkerServer::reject_query_protocol();
10376 let client = Client::builder(server.base_url())
10377 .timeout(Duration::from_secs(2))
10378 .build()
10379 .expect("client");
10380
10381 let error = client
10382 .poll_query_task("capture-worker", "capture", Duration::from_millis(10))
10383 .await
10384 .expect_err("server below query protocol floor must reject");
10385 let Error::Protocol(failure) = error else {
10386 panic!("expected typed protocol failure");
10387 };
10388
10389 assert_eq!(failure.status, 400);
10390 assert_eq!(failure.reason, "unsupported_protocol_version");
10391 assert_eq!(failure.supported_version.as_deref(), Some("1.7"));
10392 assert_eq!(
10393 failure.requested_version.as_deref(),
10394 Some(QUERY_TASK_MINIMUM_WORKER_PROTOCOL_VERSION)
10395 );
10396 assert_eq!(
10397 server
10398 .worker_protocol_for("/api/worker/query-tasks/poll")
10399 .as_deref(),
10400 Some(QUERY_TASK_MINIMUM_WORKER_PROTOCOL_VERSION)
10401 );
10402 }
10403
10404 #[tokio::test]
10405 async fn run_once_without_query_handlers_keeps_pre_query_server_compatibility() {
10406 let server = MockWorkerServer::reject_query_protocol();
10407 let client = Client::builder(server.base_url())
10408 .timeout(Duration::from_secs(2))
10409 .build()
10410 .expect("client");
10411 let mut worker = Worker::new(client, "rust-workers")
10412 .worker_id("baseline-worker")
10413 .poll_timeout(Duration::from_millis(10));
10414
10415 worker.register_workflow("baseline.workflow", |_ctx, _input| async move {
10416 Ok(Value::Null)
10417 });
10418
10419 assert_eq!(worker.run_once().await.expect("baseline run once"), 0);
10420 assert_eq!(
10421 server
10422 .worker_protocol_for("/api/worker/workflow-tasks/poll")
10423 .as_deref(),
10424 Some(WORKER_PROTOCOL_VERSION)
10425 );
10426 assert_eq!(
10427 server.worker_protocol_for("/api/worker/query-tasks/poll"),
10428 None,
10429 "a worker without query handlers must not use the query-task endpoint"
10430 );
10431 }
10432
10433 #[tokio::test]
10434 async fn completion_time_query_rejection_is_typed_without_stopping_worker() {
10435 let server = MockWorkerServer::reject_query_completion();
10436 let client = Client::builder(server.base_url())
10437 .timeout(Duration::from_secs(2))
10438 .build()
10439 .expect("client");
10440
10441 let error = client
10442 .complete_query_task("query-late", "late-worker", 1, json!(8), JSON_CODEC)
10443 .await
10444 .expect_err("expired completion must be rejected");
10445 let Error::QueryFailed(failure) = error else {
10446 panic!("expected typed query failure");
10447 };
10448 assert_eq!(failure.status, 409);
10449 assert_eq!(failure.reason, "query_task_timed_out");
10450
10451 let mut worker = Worker::new(client, "rust-workers")
10452 .worker_id("late-worker")
10453 .poll_timeout(Duration::from_millis(10));
10454 worker.register_workflow("counter", |_ctx, _input| async move { Ok(Value::Null) });
10455 worker.register_query(
10456 "counter",
10457 "current",
10458 |_ctx, _args| async move { Ok(json!(8)) },
10459 );
10460
10461 assert_eq!(worker.run_once().await.expect("late task is handled"), 1);
10462 assert_eq!(
10463 worker
10464 .run_once()
10465 .await
10466 .expect("worker continues after late completion"),
10467 0
10468 );
10469 assert_eq!(
10470 server.request_count("/api/worker/query-tasks/query-late/complete"),
10471 2
10472 );
10473 assert_eq!(
10474 server.request_count("/api/worker/query-tasks/query-late/fail"),
10475 0,
10476 "a server completion rejection must not be reported as an encoding failure"
10477 );
10478 }
10479
10480 #[tokio::test]
10481 async fn activity_only_worker_can_shutdown_without_workflow_poller() {
10482 let server = MockWorkerServer::start();
10483 let client = Client::builder(server.base_url())
10484 .timeout(Duration::from_secs(2))
10485 .build()
10486 .expect("client");
10487 let mut worker = Worker::new(client, "rust-workers")
10488 .worker_id("activity-only-worker")
10489 .poll_timeout(Duration::from_millis(10));
10490
10491 worker.register_activity(
10492 "activity.only",
10493 |_ctx, _args| async move { Ok(Value::Null) },
10494 );
10495
10496 worker.run_until(async {}).await.expect("run worker");
10497 }
10498
10499 #[tokio::test]
10500 async fn workflow_only_worker_can_shutdown_without_activity_poller() {
10501 let server = MockWorkerServer::start();
10502 let client = Client::builder(server.base_url())
10503 .timeout(Duration::from_secs(2))
10504 .build()
10505 .expect("client");
10506 let mut worker = Worker::new(client, "rust-workers")
10507 .worker_id("workflow-only-worker")
10508 .poll_timeout(Duration::from_millis(10));
10509
10510 worker.register_workflow(
10511 "workflow.only",
10512 |_ctx, _input| async move { Ok(Value::Null) },
10513 );
10514
10515 worker.run_until(async {}).await.expect("run worker");
10516 }
10517
10518 #[tokio::test]
10519 async fn worker_heartbeat_observer_receives_server_acknowledgements() {
10520 let server = MockWorkerServer::start();
10521 let client = Client::builder(server.base_url())
10522 .timeout(Duration::from_secs(2))
10523 .build()
10524 .expect("client");
10525 let observations = Arc::new(Mutex::new(Vec::new()));
10526 let observed = Arc::clone(&observations);
10527 let mut worker = Worker::new(client, "rust-workers")
10528 .worker_id("observed-heartbeat-worker")
10529 .poll_timeout(Duration::from_millis(10))
10530 .on_worker_heartbeat(move |observation| {
10531 observed
10532 .lock()
10533 .expect("heartbeat observations")
10534 .push(observation.clone());
10535 });
10536
10537 worker.register_workflow("workflow.observed", |_ctx, _input| async move {
10538 Ok(Value::Null)
10539 });
10540 worker
10541 .run_until(tokio::time::sleep(Duration::from_millis(20)))
10542 .await
10543 .expect("run worker");
10544
10545 let observations = observations.lock().expect("heartbeat observations");
10546 let first = observations.first().expect("heartbeat acknowledgement");
10547 assert_eq!(first.worker_id, "observed-heartbeat-worker");
10548 assert_eq!(first.task_queue, "rust-workers");
10549 assert!(first.acknowledged_at_unix_millis > 0);
10550 assert_eq!(first.acknowledgement, json!({}));
10551 }
10552
10553 #[tokio::test]
10554 async fn delayed_worker_heartbeat_keeps_cadence_and_pollers_live() {
10555 let server = MockWorkerServer::delayed_heartbeat_worker();
10556 let client = Client::builder(server.base_url())
10557 .timeout(Duration::from_secs(3))
10558 .build()
10559 .expect("client");
10560 let observations = Arc::new(Mutex::new(Vec::new()));
10561 let observed = Arc::clone(&observations);
10562 let mut worker = Worker::new(client, "rust-snapshot-workers")
10563 .worker_id("rust-snapshot-worker")
10564 .poll_timeout(Duration::from_millis(10))
10565 .on_worker_heartbeat(move |observation| {
10566 observed
10567 .lock()
10568 .expect("heartbeat observations")
10569 .push(observation.clone());
10570 });
10571
10572 worker.register_workflow("snapshot", |ctx, _input| async move {
10573 ctx.wait_signal("finish").await?;
10574 Ok(json!({"status": "finished"}))
10575 });
10576 worker.register_query("snapshot", "current", |ctx, _args| async move {
10577 Ok(json!(ctx
10578 .signals("increment")
10579 .iter()
10580 .filter_map(|arguments| arguments.first().and_then(Value::as_i64))
10581 .sum::<i64>()))
10582 });
10583 worker.register_activity("cancel-aware", |_ctx, _args| async move {
10584 Ok(json!({"late": "completion"}))
10585 });
10586
10587 worker
10588 .run_until(tokio::time::sleep(Duration::from_millis(3_800)))
10589 .await
10590 .expect("delayed heartbeat must allow a clean worker shutdown");
10591
10592 let observations = observations.lock().expect("heartbeat observations");
10593 assert!(
10594 observations.len() >= 3,
10595 "the immediate heartbeat, delayed acknowledgement, and next cadence heartbeat must complete"
10596 );
10597 assert!(
10598 observations.windows(2).all(|pair| {
10599 pair[1].acknowledged_at_unix_millis
10600 .saturating_sub(pair[0].acknowledged_at_unix_millis)
10601 >= 850
10602 }),
10603 "successful acknowledgements must not catch up faster than the advertised one-second cadence: {observations:?}"
10604 );
10605 drop(observations);
10606
10607 let heartbeat_times = server.request_times("/api/worker/heartbeat");
10608 let delayed_request_at = *heartbeat_times
10609 .get(1)
10610 .expect("intentionally delayed heartbeat request");
10611 let delay_window_start = delayed_request_at + Duration::from_millis(100);
10612 let delay_window_end = delayed_request_at + Duration::from_millis(1_400);
10613 for path in [
10614 "/api/worker/workflow-tasks/poll",
10615 "/api/worker/activity-tasks/poll",
10616 "/api/worker/query-tasks/poll",
10617 ] {
10618 assert!(
10619 server
10620 .request_times(path)
10621 .iter()
10622 .any(|received_at| *received_at >= delay_window_start
10623 && *received_at <= delay_window_end),
10624 "{path} must keep polling while a heartbeat acknowledgement is delayed"
10625 );
10626 }
10627 assert!(
10628 server.request_count("/api/worker/workflow-tasks/snapshot-wait-3/fail") >= 1,
10629 "workflow work must be settled"
10630 );
10631 assert!(
10632 server.request_count("/api/worker/activity-tasks/activity-cancel/complete") >= 1,
10633 "activity work must be settled"
10634 );
10635 assert!(
10636 server.request_count("/api/worker/query-tasks/snapshot-current/complete") >= 1,
10637 "query work must be settled"
10638 );
10639 }
10640
10641 #[tokio::test]
10642 async fn retried_worker_heartbeat_restarts_the_advertised_cadence() {
10643 let server = MockWorkerServer::heartbeat_retry_worker();
10644 let client = Client::builder(server.base_url())
10645 .timeout(Duration::from_secs(2))
10646 .build()
10647 .expect("client");
10648 let observations = Arc::new(Mutex::new(Vec::new()));
10649 let observed = Arc::clone(&observations);
10650 let worker = Worker::new(client, "rust-workers")
10651 .worker_id("heartbeat-retry-worker")
10652 .retry_policy(WorkerRetryPolicy {
10653 max_retries: 1,
10654 initial_backoff: Duration::from_millis(300),
10655 max_backoff: Duration::from_millis(300),
10656 })
10657 .on_worker_heartbeat(move |observation| {
10658 observed
10659 .lock()
10660 .expect("heartbeat observations")
10661 .push(observation.clone());
10662 });
10663
10664 worker
10665 .run_until(tokio::time::sleep(Duration::from_millis(2_700)))
10666 .await
10667 .expect("retryable heartbeat failure must remain bounded and recover");
10668
10669 let observations = observations.lock().expect("heartbeat observations");
10670 assert!(observations.len() >= 3, "heartbeat retry must recover");
10671 assert!(
10672 observations.windows(2).all(|pair| {
10673 pair[1]
10674 .acknowledged_at_unix_millis
10675 .saturating_sub(pair[0].acknowledged_at_unix_millis)
10676 >= 850
10677 }),
10678 "a successful retry must start a fresh advertised cadence: {observations:?}"
10679 );
10680 assert_eq!(
10681 server.request_count("/api/worker/heartbeat"),
10682 observations.len() + 1,
10683 "one retryable failure must add exactly one bounded request"
10684 );
10685 }
10686
10687 #[tokio::test]
10688 async fn query_enabled_worker_stays_live_when_signal_replay_emits_no_commands() {
10689 let server = MockWorkerServer::waiting_query_worker();
10690 let client = Client::builder(server.base_url())
10691 .timeout(Duration::from_secs(2))
10692 .build()
10693 .expect("client");
10694 let observations = Arc::new(Mutex::new(Vec::new()));
10695 let observed = Arc::clone(&observations);
10696 let mut worker = Worker::new(client, "rust-snapshot-workers")
10697 .worker_id("rust-snapshot-worker")
10698 .poll_timeout(Duration::from_millis(10))
10699 .on_worker_heartbeat(move |observation| {
10700 observed
10701 .lock()
10702 .expect("heartbeat observations")
10703 .push(observation.clone());
10704 });
10705
10706 worker.register_workflow("snapshot", |ctx, _input| async move {
10707 ctx.wait_signal("finish").await?;
10708 Ok(json!({"status": "finished"}))
10709 });
10710 worker.register_query("snapshot", "current", |ctx, _args| async move {
10711 let current = ctx
10712 .signals("increment")
10713 .iter()
10714 .filter_map(|arguments| arguments.first().and_then(Value::as_i64))
10715 .sum::<i64>();
10716 Ok(json!(current))
10717 });
10718
10719 worker
10720 .run_until(tokio::time::sleep(Duration::from_millis(3_200)))
10721 .await
10722 .expect("pending workflow and query poller must remain live until shutdown");
10723
10724 assert!(
10725 observations.lock().expect("heartbeat observations").len() >= 4,
10726 "the immediate heartbeat and at least three advertised one-second intervals must be acknowledged"
10727 );
10728 assert!(
10729 server.request_count("/api/worker/workflow-tasks/poll") >= 3,
10730 "workflow polling must continue after empty replay acknowledgements"
10731 );
10732 assert!(
10733 server.request_count("/api/worker/query-tasks/poll") >= 2,
10734 "query polling must continue after serving the current query"
10735 );
10736 assert_eq!(
10737 server.request_body("/api/worker/register")["capabilities"],
10738 json!([QUERY_TASKS_CAPABILITY])
10739 );
10740
10741 for task_id in ["snapshot-wait-3", "snapshot-wait-5"] {
10742 let fail_path = format!("/api/worker/workflow-tasks/{task_id}/fail");
10743 let completion_path = format!("/api/worker/workflow-tasks/{task_id}/complete");
10744 let failure = server.request_body(&fail_path);
10745 assert_eq!(
10746 failure["failure"]["type"],
10747 WORKFLOW_TASK_WAITING_FOR_HISTORY_TYPE
10748 );
10749 assert_eq!(server.request_count(&completion_path), 0);
10750 }
10751
10752 let query_completion =
10753 server.request_body("/api/worker/query-tasks/snapshot-current/complete");
10754 assert_eq!(query_completion["result"], json!(8));
10755 }
10756
10757 #[tokio::test]
10758 async fn worker_retries_poll_and_heartbeat_transport_failures_independently() {
10759 let server = MockWorkerServer::transient_worker_failures();
10760 let client = Client::builder(server.base_url())
10761 .timeout(Duration::from_secs(2))
10762 .build()
10763 .expect("client");
10764 let mut worker = Worker::new(client, "rust-workers")
10765 .worker_id("retry-worker")
10766 .poll_timeout(Duration::from_millis(10))
10767 .retry_policy(WorkerRetryPolicy {
10768 max_retries: 2,
10769 initial_backoff: Duration::from_millis(1),
10770 max_backoff: Duration::from_millis(1),
10771 });
10772 worker.register_workflow("counter", |_ctx, _input| async move { Ok(Value::Null) });
10773 worker.register_activity(
10774 "counter.activity",
10775 |_ctx, _input| async move { Ok(Value::Null) },
10776 );
10777 worker.register_query(
10778 "counter",
10779 "current",
10780 |_ctx, _args| async move { Ok(json!(8)) },
10781 );
10782
10783 worker
10784 .run_until(tokio::time::sleep(Duration::from_millis(75)))
10785 .await
10786 .expect("transient failures must not stop the worker");
10787
10788 for path in [
10789 "/api/worker/heartbeat",
10790 "/api/worker/workflow-tasks/poll",
10791 "/api/worker/activity-tasks/poll",
10792 "/api/worker/query-tasks/poll",
10793 ] {
10794 assert!(
10795 server.request_count(path) >= 2,
10796 "{path} must continue after its transient failure"
10797 );
10798 }
10799 }
10800
10801 #[tokio::test]
10802 async fn worker_bounds_transport_retries() {
10803 let server = MockWorkerServer::unavailable_polls();
10804 let client = Client::builder(server.base_url())
10805 .timeout(Duration::from_secs(2))
10806 .build()
10807 .expect("client");
10808 let mut worker = Worker::new(client, "rust-workers")
10809 .worker_id("bounded-retry-worker")
10810 .poll_timeout(Duration::from_millis(10))
10811 .retry_policy(WorkerRetryPolicy {
10812 max_retries: 2,
10813 initial_backoff: Duration::from_millis(1),
10814 max_backoff: Duration::from_millis(1),
10815 });
10816 worker.register_workflow("counter", |_ctx, _input| async move { Ok(Value::Null) });
10817
10818 let error = worker.run().await.expect_err("retry bound must terminate");
10819 assert!(matches!(error, Error::Transport(_)));
10820 assert_eq!(
10821 server.request_count("/api/worker/workflow-tasks/poll"),
10822 3,
10823 "one initial request plus two retries"
10824 );
10825 }
10826
10827 #[tokio::test]
10828 async fn worker_does_not_retry_authentication_failures() {
10829 let server = MockWorkerServer::unauthorized_polls();
10830 let client = Client::builder(server.base_url())
10831 .timeout(Duration::from_secs(2))
10832 .build()
10833 .expect("client");
10834 let mut worker = Worker::new(client, "rust-workers")
10835 .worker_id("unauthorized-worker")
10836 .poll_timeout(Duration::from_millis(10));
10837 worker.register_workflow("counter", |_ctx, _input| async move { Ok(Value::Null) });
10838
10839 let error = worker
10840 .run()
10841 .await
10842 .expect_err("authentication must terminate");
10843 let Error::Http { status, body } = error else {
10844 panic!("expected stable HTTP authentication error");
10845 };
10846 assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED);
10847 assert!(body.contains("authentication_failed"));
10848 assert_eq!(
10849 server.request_count("/api/worker/workflow-tasks/poll"),
10850 1,
10851 "authentication failures must not be retried"
10852 );
10853 }
10854
10855 #[derive(Clone, Debug)]
10856 struct CapturedRequest {
10857 path: String,
10858 worker_protocol: Option<String>,
10859 body: String,
10860 received_at: Instant,
10861 }
10862
10863 struct MockWorkerServer {
10864 addr: SocketAddr,
10865 stop: Arc<AtomicBool>,
10866 requests: Arc<Mutex<Vec<CapturedRequest>>>,
10867 thread: Option<thread::JoinHandle<()>>,
10868 }
10869
10870 #[derive(Clone, Copy, Default)]
10871 struct MockWorkerBehavior {
10872 reject_query_protocol: bool,
10873 reject_query_completion: bool,
10874 waiting_query_worker: bool,
10875 poll_failures_per_path: usize,
10876 heartbeat_failures: usize,
10877 heartbeat_failure_request: Option<usize>,
10878 delayed_heartbeat_request: Option<usize>,
10879 heartbeat_response_delay: Duration,
10880 concurrent_requests: bool,
10881 unauthorized_polls: bool,
10882 cancelled_activity: bool,
10883 draining_polls: bool,
10884 workflow_completion_status: Option<&'static str>,
10885 workflow_completion_body: Option<&'static str>,
10886 }
10887
10888 impl MockWorkerServer {
10889 fn start() -> Self {
10890 Self::start_with_behavior(MockWorkerBehavior::default())
10891 }
10892
10893 fn reject_query_protocol() -> Self {
10894 Self::start_with_behavior(MockWorkerBehavior {
10895 reject_query_protocol: true,
10896 ..MockWorkerBehavior::default()
10897 })
10898 }
10899
10900 fn reject_query_completion() -> Self {
10901 Self::start_with_behavior(MockWorkerBehavior {
10902 reject_query_completion: true,
10903 ..MockWorkerBehavior::default()
10904 })
10905 }
10906
10907 fn waiting_query_worker() -> Self {
10908 Self::start_with_behavior(MockWorkerBehavior {
10909 waiting_query_worker: true,
10910 ..MockWorkerBehavior::default()
10911 })
10912 }
10913
10914 fn transient_worker_failures() -> Self {
10915 Self::start_with_behavior(MockWorkerBehavior {
10916 poll_failures_per_path: 1,
10917 heartbeat_failures: 1,
10918 ..MockWorkerBehavior::default()
10919 })
10920 }
10921
10922 fn delayed_heartbeat_worker() -> Self {
10923 Self::start_with_behavior(MockWorkerBehavior {
10924 waiting_query_worker: true,
10925 delayed_heartbeat_request: Some(2),
10926 heartbeat_response_delay: Duration::from_millis(1_500),
10927 concurrent_requests: true,
10928 cancelled_activity: true,
10929 ..MockWorkerBehavior::default()
10930 })
10931 }
10932
10933 fn heartbeat_retry_worker() -> Self {
10934 Self::start_with_behavior(MockWorkerBehavior {
10935 waiting_query_worker: true,
10936 heartbeat_failure_request: Some(2),
10937 concurrent_requests: true,
10938 ..MockWorkerBehavior::default()
10939 })
10940 }
10941
10942 fn unavailable_polls() -> Self {
10943 Self::start_with_behavior(MockWorkerBehavior {
10944 poll_failures_per_path: usize::MAX,
10945 ..MockWorkerBehavior::default()
10946 })
10947 }
10948
10949 fn unauthorized_polls() -> Self {
10950 Self::start_with_behavior(MockWorkerBehavior {
10951 unauthorized_polls: true,
10952 ..MockWorkerBehavior::default()
10953 })
10954 }
10955
10956 fn cancelled_activity() -> Self {
10957 Self::start_with_behavior(MockWorkerBehavior {
10958 cancelled_activity: true,
10959 ..MockWorkerBehavior::default()
10960 })
10961 }
10962
10963 fn draining_polls() -> Self {
10964 Self::start_with_behavior(MockWorkerBehavior {
10965 draining_polls: true,
10966 ..MockWorkerBehavior::default()
10967 })
10968 }
10969
10970 fn workflow_completion(status: &'static str, body: &'static str) -> Self {
10971 Self::start_with_behavior(MockWorkerBehavior {
10972 workflow_completion_status: Some(status),
10973 workflow_completion_body: Some(body),
10974 ..MockWorkerBehavior::default()
10975 })
10976 }
10977
10978 fn start_with_behavior(behavior: MockWorkerBehavior) -> Self {
10979 let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server");
10980 listener
10981 .set_nonblocking(true)
10982 .expect("configure mock listener");
10983 let addr = listener.local_addr().expect("mock server address");
10984 let stop = Arc::new(AtomicBool::new(false));
10985 let server_stop = Arc::clone(&stop);
10986 let requests = Arc::new(Mutex::new(Vec::new()));
10987 let server_requests = Arc::clone(&requests);
10988 let thread = thread::spawn(move || {
10989 let mut request_threads = Vec::new();
10990 while !server_stop.load(Ordering::SeqCst) {
10991 match listener.accept() {
10992 Ok((mut stream, _)) => {
10993 if behavior.concurrent_requests {
10994 let requests = Arc::clone(&server_requests);
10995 request_threads.push(thread::spawn(move || {
10996 handle_mock_worker_request(&mut stream, &requests, behavior)
10997 }));
10998 } else {
10999 handle_mock_worker_request(&mut stream, &server_requests, behavior);
11000 }
11001 }
11002 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
11003 let mut index = 0;
11004 while index < request_threads.len() {
11005 if request_threads[index].is_finished() {
11006 request_threads
11007 .swap_remove(index)
11008 .join()
11009 .expect("join mock request");
11010 } else {
11011 index += 1;
11012 }
11013 }
11014 thread::sleep(Duration::from_millis(5));
11015 }
11016 Err(_) => break,
11017 }
11018 }
11019 for request_thread in request_threads {
11020 request_thread.join().expect("join mock request");
11021 }
11022 });
11023
11024 Self {
11025 addr,
11026 stop,
11027 requests,
11028 thread: Some(thread),
11029 }
11030 }
11031
11032 fn base_url(&self) -> String {
11033 format!("http://{}", self.addr)
11034 }
11035
11036 fn worker_protocol_for(&self, path: &str) -> Option<String> {
11037 self.requests
11038 .lock()
11039 .expect("captured requests")
11040 .iter()
11041 .find(|request| request.path == path)
11042 .and_then(|request| request.worker_protocol.clone())
11043 }
11044
11045 fn request_count(&self, path: &str) -> usize {
11046 self.requests
11047 .lock()
11048 .expect("captured requests")
11049 .iter()
11050 .filter(|request| request.path == path)
11051 .count()
11052 }
11053
11054 fn request_times(&self, path: &str) -> Vec<Instant> {
11055 self.requests
11056 .lock()
11057 .expect("captured requests")
11058 .iter()
11059 .filter(|request| request.path == path)
11060 .map(|request| request.received_at)
11061 .collect()
11062 }
11063
11064 fn request_body(&self, path: &str) -> Value {
11065 let requests = self.requests.lock().expect("captured requests");
11066 let body = &requests
11067 .iter()
11068 .find(|request| request.path == path)
11069 .unwrap_or_else(|| panic!("missing request for {path}"))
11070 .body;
11071 serde_json::from_str(body).unwrap_or_else(|error| {
11072 panic!("invalid JSON request body for {path}: {error}: {body:?}")
11073 })
11074 }
11075 }
11076
11077 impl Drop for MockWorkerServer {
11078 fn drop(&mut self) {
11079 self.stop.store(true, Ordering::SeqCst);
11080 let _ = TcpStream::connect(self.addr);
11081
11082 if let Some(thread) = self.thread.take() {
11083 thread.join().expect("join mock server");
11084 }
11085 }
11086 }
11087
11088 fn handle_mock_worker_request(
11089 stream: &mut TcpStream,
11090 requests: &Arc<Mutex<Vec<CapturedRequest>>>,
11091 behavior: MockWorkerBehavior,
11092 ) {
11093 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
11094 let mut buffer = [0_u8; 8192];
11095 let mut request = Vec::new();
11096
11097 loop {
11098 match stream.read(&mut buffer) {
11099 Ok(0) => break,
11100 Ok(read) => {
11101 request.extend_from_slice(&buffer[..read]);
11102 if mock_request_is_complete(&request) {
11103 break;
11104 }
11105 }
11106 Err(error)
11107 if matches!(
11108 error.kind(),
11109 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
11110 ) =>
11111 {
11112 break;
11113 }
11114 Err(_) => return,
11115 }
11116 }
11117
11118 let request = String::from_utf8_lossy(&request);
11119 let body = request
11120 .split_once("\r\n\r\n")
11121 .map(|(_, body)| body)
11122 .unwrap_or_default();
11123 let path = request
11124 .lines()
11125 .next()
11126 .and_then(|line| line.split_whitespace().nth(1))
11127 .unwrap_or_default();
11128 let worker_protocol = request.lines().find_map(|line| {
11129 let (name, value) = line.split_once(':')?;
11130 name.eq_ignore_ascii_case("X-Durable-Workflow-Protocol-Version")
11131 .then(|| value.trim().to_string())
11132 });
11133 let request_number = {
11134 let mut requests = requests.lock().expect("captured requests");
11135 requests.push(CapturedRequest {
11136 path: path.to_string(),
11137 worker_protocol: worker_protocol.clone(),
11138 body: body.to_string(),
11139 received_at: Instant::now(),
11140 });
11141 requests
11142 .iter()
11143 .filter(|request| request.path == path)
11144 .count()
11145 };
11146
11147 let is_poll = matches!(
11148 path,
11149 "/api/worker/workflow-tasks/poll"
11150 | "/api/worker/activity-tasks/poll"
11151 | "/api/worker/query-tasks/poll"
11152 );
11153 if is_poll && request_number <= behavior.poll_failures_per_path {
11154 return;
11155 }
11156 if path == "/api/worker/heartbeat" && request_number <= behavior.heartbeat_failures {
11157 return;
11158 }
11159 if path == "/api/worker/heartbeat"
11160 && behavior.heartbeat_failure_request == Some(request_number)
11161 {
11162 return;
11163 }
11164 if path == "/api/worker/heartbeat"
11165 && behavior.delayed_heartbeat_request == Some(request_number)
11166 {
11167 thread::sleep(behavior.heartbeat_response_delay);
11168 }
11169 if behavior.unauthorized_polls && is_poll {
11170 write_mock_response(
11171 stream,
11172 "401 Unauthorized",
11173 r#"{"reason":"authentication_failed","message":"invalid worker token"}"#,
11174 );
11175 return;
11176 }
11177 if behavior.draining_polls && is_poll {
11178 write_mock_response(
11179 stream,
11180 "409 Conflict",
11181 r#"{"task":null,"poll_status":"draining","reason":"worker_draining","worker_status":"draining","drain_intent":"draining"}"#,
11182 );
11183 return;
11184 }
11185
11186 if behavior.reject_query_protocol && path.starts_with("/api/worker/query-tasks/") {
11187 let requested_version = worker_protocol.as_deref().unwrap_or("missing");
11188 let body = format!(
11189 r#"{{"reason":"unsupported_protocol_version","message":"Query tasks require worker protocol 1.8 or newer.","supported_version":"1.7","requested_version":"{requested_version}"}}"#
11190 );
11191 write_mock_response(stream, "400 Bad Request", &body);
11192 return;
11193 }
11194
11195 if behavior.reject_query_completion && path == "/api/worker/query-tasks/query-late/complete"
11196 {
11197 write_mock_response(
11198 stream,
11199 "409 Conflict",
11200 r#"{"reason":"query_task_timed_out","message":"query task timed out before completion"}"#,
11201 );
11202 return;
11203 }
11204
11205 if behavior.workflow_completion_status.is_some()
11206 && path == "/api/worker/workflow-tasks/poll"
11207 && request_number == 1
11208 {
11209 write_mock_response(
11210 stream,
11211 "200 OK",
11212 r#"{"task":{"task_id":"workflow-timeout-task","workflow_id":"reused-workflow-id","run_id":"run-selected-timeout","workflow_type":"timeout.workflow","payload_codec":"json","arguments":{"codec":"json","blob":"[]"},"history_events":[],"workflow_task_attempt":3,"lease_owner":"timeout-worker"}}"#,
11213 );
11214 return;
11215 }
11216
11217 if path == "/api/worker/workflow-tasks/workflow-timeout-task/complete" {
11218 if let (Some(status), Some(body)) = (
11219 behavior.workflow_completion_status,
11220 behavior.workflow_completion_body,
11221 ) {
11222 write_mock_response(stream, status, body);
11223 return;
11224 }
11225 }
11226
11227 if behavior.waiting_query_worker {
11228 if path == "/api/worker/workflow-tasks/poll" && request_number <= 2 {
11229 let amounts = if request_number == 1 {
11230 vec![3]
11231 } else {
11232 vec![3, 5]
11233 };
11234 let task_id = if request_number == 1 {
11235 "snapshot-wait-3"
11236 } else {
11237 "snapshot-wait-5"
11238 };
11239 let history_events = std::iter::once(json!({
11240 "event_type": "SignalWaitOpened",
11241 "payload": {"sequence": 1, "signal_name": "finish"}
11242 }))
11243 .chain(amounts.iter().enumerate().map(|(index, amount)| {
11244 json!({
11245 "event_type": "SignalReceived",
11246 "payload": {
11247 "signal_id": format!("increment-{amount}"),
11248 "signal_name": "increment",
11249 "workflow_sequence": index + 2,
11250 "payload_codec": DEFAULT_CODEC,
11251 "arguments": encode_value_envelope(&json!([amount]), DEFAULT_CODEC)
11252 .expect("Avro signal envelope")
11253 }
11254 })
11255 }))
11256 .collect::<Vec<_>>();
11257 let body = json!({
11258 "task": {
11259 "task_id": task_id,
11260 "workflow_id": "snapshot-1",
11261 "run_id": "snapshot-run-1",
11262 "workflow_type": "snapshot",
11263 "payload_codec": DEFAULT_CODEC,
11264 "arguments": encode_value_envelope(&json!([]), DEFAULT_CODEC)
11265 .expect("Avro workflow arguments"),
11266 "history_events": history_events,
11267 "workflow_task_attempt": 1,
11268 "workflow_signal_id": format!("increment-{}", amounts.last().expect("amount")),
11269 "signal_name": "increment",
11270 "signal_arguments": encode_value_envelope(
11271 &json!([amounts.last().expect("amount")]),
11272 DEFAULT_CODEC,
11273 )
11274 .expect("Avro resume signal"),
11275 "lease_owner": "rust-snapshot-worker"
11276 }
11277 })
11278 .to_string();
11279 write_mock_response(stream, "200 OK", &body);
11280 return;
11281 }
11282
11283 if path == "/api/worker/query-tasks/poll" && request_number == 1 {
11284 let history_events = [3, 5]
11285 .into_iter()
11286 .enumerate()
11287 .map(|(index, amount)| {
11288 json!({
11289 "event_type": "SignalReceived",
11290 "payload": {
11291 "signal_id": format!("increment-{amount}"),
11292 "signal_name": "increment",
11293 "workflow_sequence": index + 2,
11294 "payload_codec": DEFAULT_CODEC,
11295 "arguments": encode_value_envelope(&json!([amount]), DEFAULT_CODEC)
11296 .expect("Avro query signal envelope")
11297 }
11298 })
11299 })
11300 .collect::<Vec<_>>();
11301 let body = json!({
11302 "task": {
11303 "query_task_id": "snapshot-current",
11304 "query_task_attempt": 1,
11305 "lease_owner": "rust-snapshot-worker",
11306 "workflow_id": "snapshot-1",
11307 "run_id": "snapshot-run-1",
11308 "workflow_type": "snapshot",
11309 "query_name": "current",
11310 "payload_codec": DEFAULT_CODEC,
11311 "workflow_arguments": encode_value_envelope(&json!([]), DEFAULT_CODEC)
11312 .expect("Avro workflow arguments"),
11313 "query_arguments": encode_value_envelope(&json!([]), DEFAULT_CODEC)
11314 .expect("Avro query arguments"),
11315 "history_events": history_events,
11316 "run_status": "waiting"
11317 }
11318 })
11319 .to_string();
11320 write_mock_response(stream, "200 OK", &body);
11321 return;
11322 }
11323
11324 if path == "/api/worker/workflow-tasks/snapshot-wait-3/fail"
11325 || path == "/api/worker/workflow-tasks/snapshot-wait-5/fail"
11326 {
11327 write_mock_response(
11328 stream,
11329 "200 OK",
11330 r#"{"outcome":"waiting_for_history","recorded":true}"#,
11331 );
11332 return;
11333 }
11334
11335 if path == "/api/worker/query-tasks/snapshot-current/complete" {
11336 write_mock_response(stream, "200 OK", r#"{"outcome":"completed"}"#);
11337 return;
11338 }
11339 }
11340
11341 let (status, body) = match path {
11342 "/api/workflows" => (
11343 "201 Created",
11344 r#"{"workflow_id":"wf-start-options","run_id":"run-start-options","workflow_type":"rust.timeout"}"#,
11345 ),
11346 "/api/worker/register" if behavior.waiting_query_worker => (
11347 "200 OK",
11348 r#"{"worker_id":"rust-snapshot-worker","registered":true,"heartbeat_interval_seconds":1}"#,
11349 ),
11350 "/api/worker/register" => (
11351 "200 OK",
11352 r#"{"worker_id":"mock-worker","registered":true,"heartbeat_interval_seconds":3600}"#,
11353 ),
11354 "/api/worker/heartbeat" => ("200 OK", "{}"),
11355 "/api/worker/activity-tasks/poll"
11356 if behavior.cancelled_activity && request_number == 1 =>
11357 {
11358 (
11359 "200 OK",
11360 r#"{"task":{"task_id":"activity-cancel","activity_attempt_id":"attempt-cancel","activity_type":"cancel-aware","payload_codec":"json","arguments":{"codec":"json","blob":"[]"},"attempt_number":1,"lease_owner":"rust-cancel-worker"}}"#,
11361 )
11362 }
11363 "/api/worker/activity-tasks/poll" | "/api/worker/workflow-tasks/poll" => {
11364 ("200 OK", r#"{"task":null}"#)
11365 }
11366 "/api/worker/query-tasks/poll"
11367 if behavior.reject_query_completion && request_number == 1 =>
11368 {
11369 (
11370 "200 OK",
11371 r#"{"task":{"query_task_id":"query-late","query_task_attempt":1,"lease_owner":"late-worker","workflow_id":"counter-late","run_id":"run-late","workflow_type":"counter","query_name":"current","payload_codec":"json","workflow_arguments":{"codec":"json","blob":"[]"},"query_arguments":{"codec":"json","blob":"[]"},"history_events":[],"run_status":"running"}}"#,
11372 )
11373 }
11374 "/api/worker/query-tasks/poll" => ("200 OK", r#"{"task":null}"#),
11375 "/api/worker/query-tasks/query-capture/complete"
11376 | "/api/worker/query-tasks/query-capture/fail" => ("200 OK", "{}"),
11377 "/api/worker/activity-tasks/activity-cancel/heartbeat" => (
11378 "200 OK",
11379 r#"{"activity_attempt_id":"attempt-cancel","cancel_requested":true,"can_continue":false,"reason":"run_cancelled","run_closed_reason":"cancelled","heartbeat_recorded":false}"#,
11380 ),
11381 "/api/worker/activity-tasks/activity-cancel/complete" => (
11382 "409 Conflict",
11383 r#"{"task_id":"activity-cancel","activity_attempt_id":"attempt-cancel","reason":"run_cancelled","cancel_requested":true,"can_continue":false,"run_closed_reason":"cancelled"}"#,
11384 ),
11385 "/api/workflows/counter-1/query/current" => (
11386 "200 OK",
11387 r#"{"workflow_id":"counter-1","query_name":"current","result":{"count":8},"result_envelope":{"codec":"json","blob":"{\"count\":8}"}}"#,
11388 ),
11389 "/api/workflows/counter-1/query/missing" => (
11390 "404 Not Found",
11391 r#"{"workflow_id":"counter-1","query_name":"missing","reason":"rejected_unknown_query","message":"unknown query"}"#,
11392 ),
11393 "/api/workflows/wf-lifecycle/cancel" => (
11394 "200 OK",
11395 r#"{"workflow_id":"wf-lifecycle","run_id":"run-current","outcome":"cancelled","reason":"cleanup requested","command_status":"accepted"}"#,
11396 ),
11397 "/api/workflows/wf-lifecycle/terminate" => (
11398 "200 OK",
11399 r#"{"workflow_id":"wf-lifecycle","run_id":"run-current","outcome":"terminated","reason":"forced stop","command_status":"accepted"}"#,
11400 ),
11401 "/api/workflows/wf-lifecycle/runs/run-current/cancel" => (
11402 "200 OK",
11403 r#"{"workflow_id":"wf-lifecycle","run_id":"run-current","outcome":"cancelled","command_status":"accepted"}"#,
11404 ),
11405 "/api/workflows/wf-lifecycle/runs/run-current/terminate" => (
11406 "200 OK",
11407 r#"{"workflow_id":"wf-lifecycle","run_id":"run-current","outcome":"terminated","command_status":"accepted"}"#,
11408 ),
11409 "/api/workflows/wf-lifecycle/runs/run-stale/cancel"
11410 | "/api/workflows/wf-lifecycle/runs/run-stale/terminate" => (
11411 "409 Conflict",
11412 r#"{"workflow_id":"wf-lifecycle","run_id":"run-stale","reason":"historical_run_command_rejected","target_scope":"run","message":"Commands cannot target historical runs."}"#,
11413 ),
11414 "/api/workflows/wf-failed" | "/api/workflows/wf-failed/runs/run-failed" => (
11415 "200 OK",
11416 r#"{"workflow_id":"wf-failed","run_id":"run-failed","status":"failed","closed_reason":"failed","error":"payment failed","failure":{"message":"payment failed","failure_category":"application","exception_type":"PaymentError","exception_class":"billing::PaymentError","non_retryable":true,"exception":{"type":"PaymentError","class":"billing::PaymentError","message":"payment failed"},"failures":[{"id":"failure-17","failure_category":"application"}]}}"#,
11417 ),
11418 "/api/workflows/wf-cancelled" => (
11419 "200 OK",
11420 r#"{"workflow_id":"wf-cancelled","run_id":"run-cancelled","status":"cancelled","closed_reason":"cancelled","reason":"cleanup requested"}"#,
11421 ),
11422 "/api/workflows/wf-terminated" => (
11423 "200 OK",
11424 r#"{"workflow_id":"wf-terminated","run_id":"run-terminated","status":"terminated","closed_reason":"terminated","reason":"forced stop"}"#,
11425 ),
11426 "/api/workflows/wf-timed-out" => (
11427 "200 OK",
11428 r#"{"workflow_id":"wf-timed-out","run_id":"run-timed-out","status":"failed","closed_reason":"timed_out","reason":"run_timeout"}"#,
11429 ),
11430 "/api/workflows/wf-waiting" | "/api/workflows/wf-waiting/runs/run-waiting" => (
11431 "200 OK",
11432 r#"{"workflow_id":"wf-waiting","run_id":"run-waiting","status":"waiting"}"#,
11433 ),
11434 "/api/workflows/wf-selected" => (
11435 "200 OK",
11436 r#"{"workflow_id":"wf-selected","run_id":"run-current","status":"completed","output":"current run output"}"#,
11437 ),
11438 "/api/workflows/wf-selected/runs/run-selected" => (
11439 "200 OK",
11440 r#"{"workflow_id":"wf-selected","run_id":"run-selected","status":"cancelled","closed_reason":"cancelled","reason":"selected run cancelled"}"#,
11441 ),
11442 _ => ("404 Not Found", r#"{"message":"not found"}"#),
11443 };
11444 write_mock_response(stream, status, body);
11445 }
11446
11447 fn mock_request_is_complete(request: &[u8]) -> bool {
11448 let Some(header_end) = request
11449 .windows(4)
11450 .position(|window| window == b"\r\n\r\n")
11451 .map(|position| position + 4)
11452 else {
11453 return false;
11454 };
11455 let headers = String::from_utf8_lossy(&request[..header_end]);
11456 let content_length = headers.lines().find_map(|line| {
11457 let (name, value) = line.split_once(':')?;
11458 name.eq_ignore_ascii_case("content-length")
11459 .then(|| value.trim().parse::<usize>().ok())
11460 .flatten()
11461 });
11462
11463 request.len() >= header_end + content_length.unwrap_or(0)
11464 }
11465
11466 fn write_mock_response(stream: &mut TcpStream, status: &str, body: &str) {
11467 let response = format!(
11468 "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
11469 body.len()
11470 );
11471
11472 let _ = stream.write_all(response.as_bytes());
11473 let _ = stream.flush();
11474 }
11475}