1use crate::{
2 BackfillScheduleInput, Client, CreateScheduleInput, CreateScheduleOutput, DeleteScheduleInput,
3 DescribeScheduleInput, DescribeScheduleOutput, ListSchedulesPageInput, ListSchedulesPageOutput,
4 NamespacedClient, Next, PauseScheduleInput, RpcOptions, SendScheduleUpdateInput,
5 TriggerScheduleInput, UnpauseScheduleInput, UpdateScheduleInput, grpc::WorkflowService,
6 interceptors,
7};
8use futures_util::{FutureExt, future::BoxFuture, stream};
9use std::{
10 collections::VecDeque,
11 pin::Pin,
12 sync::Arc,
13 task::{Context, Poll},
14 time::{Duration, SystemTime},
15};
16use temporalio_common::{
17 HasWorkflowDefinition,
18 data_converters::{
19 DataConverter, PayloadConversionError, SerializationContextData, TemporalDeserializable,
20 TemporalSerializable, WorkflowSerializationContext,
21 },
22 payload_visitor::decode_payloads,
23 protos::{
24 coresdk::IntoPayloadsExt,
25 proto_ts_to_system_time,
26 temporal::api::{
27 common::v1 as common_proto, schedule::v1 as schedule_proto,
28 taskqueue::v1 as taskqueue_proto, workflow::v1 as workflow_proto,
29 workflowservice::v1::*,
30 },
31 },
32 search_attributes::SearchAttributes,
33};
34use tonic::IntoRequest;
35use uuid::Uuid;
36
37#[derive(Debug, thiserror::Error)]
39#[non_exhaustive]
40pub enum ScheduleError {
41 #[error("Server error: {0}")]
43 Rpc(#[from] tonic::Status),
44 #[error("Payload conversion error: {0}")]
46 PayloadConversion(#[from] PayloadConversionError),
47 #[error("Malformed schedule description for schedule ID '{schedule_id}': {reason}")]
49 MalformedDescription {
50 schedule_id: String,
52 reason: String,
54 },
55}
56
57trait SerializableScheduleInput: Send + Sync {
58 fn to_payloads<'a>(
59 &'a self,
60 dc: &'a DataConverter,
61 context: &'a SerializationContextData,
62 ) -> BoxFuture<'a, Result<Vec<common_proto::Payload>, PayloadConversionError>>;
63}
64
65impl<T> SerializableScheduleInput for T
66where
67 T: TemporalSerializable + Send + Sync + 'static,
68{
69 fn to_payloads<'a>(
70 &'a self,
71 dc: &'a DataConverter,
72 context: &'a SerializationContextData,
73 ) -> BoxFuture<'a, Result<Vec<common_proto::Payload>, PayloadConversionError>> {
74 dc.to_payloads(context, self).boxed()
75 }
76}
77
78#[derive(derive_more::Debug, Clone)]
80pub struct ScheduleWorkflowInput {
81 repr: ScheduleWorkflowInputRepr,
82}
83
84#[derive(derive_more::Debug, Clone)]
85enum ScheduleWorkflowInputRepr {
86 #[debug("Deferred(...)")]
87 Deferred(#[debug(skip)] Arc<dyn SerializableScheduleInput>),
88}
89
90impl ScheduleWorkflowInput {
91 fn new_deferred<T>(val: T) -> Self
92 where
93 T: SerializableScheduleInput + 'static,
94 {
95 Self {
96 repr: ScheduleWorkflowInputRepr::Deferred(Arc::new(val)),
97 }
98 }
99
100 pub(crate) async fn into_payloads(
101 self,
102 dc: &DataConverter,
103 ) -> Result<Vec<common_proto::Payload>, PayloadConversionError> {
104 let ScheduleWorkflowInputRepr::Deferred(v) = self.repr;
105 v.to_payloads(
106 dc,
107 &SerializationContextData::Workflow(WorkflowSerializationContext::new()),
108 )
109 .await
110 }
111}
112
113#[derive(Debug, Clone, bon::Builder)]
115#[builder(on(String, into))]
116#[non_exhaustive]
117pub struct CreateScheduleOptions {
118 pub action: ScheduleAction,
120 pub spec: ScheduleSpec,
122 #[builder(default)]
124 pub trigger_immediately: bool,
125 #[builder(default)]
128 pub overlap_policy: ScheduleOverlapPolicy,
129 #[builder(default)]
131 pub paused: bool,
132 #[builder(default)]
134 pub note: String,
135 #[builder(default)]
137 pub rpc_options: RpcOptions,
138}
139
140#[derive(derive_more::Debug, Clone)]
143#[non_exhaustive]
144pub enum ScheduleAction {
145 StartWorkflow {
147 workflow_type: String,
149 task_queue: String,
151 workflow_id: String,
153 input: Option<ScheduleWorkflowInput>,
155 },
156}
157
158impl ScheduleAction {
159 pub fn start_workflow<W>(
161 workflow: W,
162 input: W::Input,
163 task_queue: impl Into<String>,
164 workflow_id: impl Into<String>,
165 ) -> Self
166 where
167 W: HasWorkflowDefinition,
168 W::Input: TemporalSerializable + Send + Sync + 'static,
169 {
170 Self::StartWorkflow {
171 workflow_type: workflow.name().to_string(),
172 task_queue: task_queue.into(),
173 workflow_id: workflow_id.into(),
174 input: Some(ScheduleWorkflowInput::new_deferred(input)),
175 }
176 }
177
178 pub(crate) async fn into_proto(
179 self,
180 dc: &DataConverter,
181 ) -> Result<schedule_proto::ScheduleAction, PayloadConversionError> {
182 match self {
183 Self::StartWorkflow {
184 workflow_type,
185 task_queue,
186 workflow_id,
187 input,
188 } => {
189 let input = if let Some(wi) = input {
190 wi.into_payloads(dc).await?.into_payloads()
191 } else {
192 None
193 };
194 Ok(schedule_proto::ScheduleAction {
195 action: Some(schedule_proto::schedule_action::Action::StartWorkflow(
196 workflow_proto::NewWorkflowExecutionInfo {
197 workflow_id,
198 workflow_type: Some(common_proto::WorkflowType {
199 name: workflow_type,
200 }),
201 task_queue: Some(taskqueue_proto::TaskQueue {
202 name: task_queue,
203 ..Default::default()
204 }),
205 input,
206 ..Default::default()
207 },
208 )),
209 })
210 }
211 }
212 }
213}
214
215#[derive(Debug, Clone, Default, PartialEq, bon::Builder)]
220#[builder(on(String, into))]
221#[non_exhaustive]
222pub struct ScheduleSpec {
223 #[builder(default)]
225 pub intervals: Vec<ScheduleIntervalSpec>,
226 #[builder(default)]
228 pub calendars: Vec<ScheduleCalendarSpec>,
229 #[builder(default)]
231 pub exclude_calendars: Vec<ScheduleCalendarSpec>,
232 #[builder(default)]
234 pub cron_strings: Vec<String>,
235 #[builder(default)]
237 pub timezone_name: String,
238 pub start_time: Option<SystemTime>,
240 pub end_time: Option<SystemTime>,
242 pub jitter: Option<Duration>,
244}
245
246impl ScheduleSpec {
247 pub fn from_interval(every: Duration) -> Self {
249 Self {
250 intervals: vec![every.into()],
251 ..Default::default()
252 }
253 }
254
255 pub fn from_calendar(calendar: ScheduleCalendarSpec) -> Self {
257 Self {
258 calendars: vec![calendar],
259 ..Default::default()
260 }
261 }
262
263 pub(crate) fn into_proto(self) -> schedule_proto::ScheduleSpec {
264 #[allow(deprecated)]
265 schedule_proto::ScheduleSpec {
266 interval: self.intervals.into_iter().map(Into::into).collect(),
267 calendar: self.calendars.into_iter().map(Into::into).collect(),
268 exclude_calendar: self.exclude_calendars.into_iter().map(Into::into).collect(),
269 cron_string: self.cron_strings,
270 timezone_name: self.timezone_name,
271 start_time: self.start_time.map(Into::into),
272 end_time: self.end_time.map(Into::into),
273 jitter: self.jitter.and_then(|d| d.try_into().ok()),
274 ..Default::default()
275 }
276 }
277}
278
279#[derive(Debug, Clone, PartialEq)]
281#[non_exhaustive]
282pub struct ScheduleIntervalSpec {
283 pub every: Duration,
285 pub offset: Option<Duration>,
287}
288
289impl ScheduleIntervalSpec {
290 pub fn new(every: Duration, offset: Option<Duration>) -> Self {
292 Self { every, offset }
293 }
294}
295
296impl From<Duration> for ScheduleIntervalSpec {
297 fn from(every: Duration) -> Self {
298 Self {
299 every,
300 offset: None,
301 }
302 }
303}
304
305impl From<ScheduleIntervalSpec> for schedule_proto::IntervalSpec {
306 fn from(s: ScheduleIntervalSpec) -> Self {
307 Self {
308 interval: Some(s.every.try_into().unwrap_or_default()),
309 phase: s.offset.and_then(|d| d.try_into().ok()),
310 }
311 }
312}
313
314#[derive(Debug, Clone, Default, PartialEq, bon::Builder)]
318#[builder(on(String, into))]
319#[non_exhaustive]
320pub struct ScheduleCalendarSpec {
321 #[builder(default)]
323 pub second: String,
324 #[builder(default)]
326 pub minute: String,
327 #[builder(default)]
329 pub hour: String,
330 #[builder(default)]
332 pub day_of_month: String,
333 #[builder(default)]
335 pub month: String,
336 #[builder(default)]
338 pub day_of_week: String,
339 #[builder(default)]
341 pub year: String,
342 #[builder(default)]
344 pub comment: String,
345}
346
347impl From<ScheduleCalendarSpec> for schedule_proto::CalendarSpec {
348 fn from(s: ScheduleCalendarSpec) -> Self {
349 Self {
350 second: s.second,
351 minute: s.minute,
352 hour: s.hour,
353 day_of_month: s.day_of_month,
354 month: s.month,
355 day_of_week: s.day_of_week,
356 year: s.year,
357 comment: s.comment,
358 }
359 }
360}
361
362#[derive(Debug, Clone, Default, bon::Builder)]
364#[non_exhaustive]
365pub struct ListSchedulesOptions {
366 #[builder(default)]
368 pub maximum_page_size: i32,
369 #[builder(default)]
371 pub query: String,
372 #[builder(default)]
374 pub rpc_options: RpcOptions,
375}
376
377#[derive(Debug, Clone, Default, bon::Builder)]
379#[non_exhaustive]
380pub struct DeleteScheduleOptions {
381 #[builder(default)]
383 pub rpc_options: RpcOptions,
384}
385
386#[derive(Debug, Clone, Default, bon::Builder)]
388#[non_exhaustive]
389pub struct PauseScheduleOptions {
390 #[builder(default)]
392 pub rpc_options: RpcOptions,
393}
394
395#[derive(Debug, Clone, Default, bon::Builder)]
397#[non_exhaustive]
398pub struct UnpauseScheduleOptions {
399 #[builder(default)]
401 pub rpc_options: RpcOptions,
402}
403
404#[derive(Debug, Clone, Default, bon::Builder)]
406#[non_exhaustive]
407pub struct TriggerScheduleOptions {
408 #[builder(default)]
410 pub rpc_options: RpcOptions,
411}
412
413#[derive(Debug, Clone, Default, bon::Builder)]
415#[non_exhaustive]
416pub struct BackfillScheduleOptions {
417 #[builder(default)]
419 pub rpc_options: RpcOptions,
420}
421
422pub struct ListSchedulesStream {
425 inner: Pin<Box<dyn futures_util::Stream<Item = Result<ScheduleSummary, ScheduleError>> + Send>>,
426}
427
428impl ListSchedulesStream {
429 pub(crate) fn new(
430 inner: Pin<
431 Box<dyn futures_util::Stream<Item = Result<ScheduleSummary, ScheduleError>> + Send>,
432 >,
433 ) -> Self {
434 Self { inner }
435 }
436}
437
438impl futures_util::Stream for ListSchedulesStream {
439 type Item = Result<ScheduleSummary, ScheduleError>;
440
441 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
442 self.inner.as_mut().poll_next(cx)
443 }
444}
445
446#[derive(Debug, Clone, PartialEq)]
448#[non_exhaustive]
449pub struct ScheduleRecentAction {
450 pub schedule_time: Option<SystemTime>,
452 pub actual_time: Option<SystemTime>,
454 pub workflow_id: String,
456 pub run_id: String,
458}
459
460#[derive(Debug, Clone, PartialEq)]
462#[non_exhaustive]
463pub struct ScheduleRunningAction {
464 pub workflow_id: String,
466 pub run_id: String,
468}
469
470#[derive(Debug, Clone)]
472#[non_exhaustive]
473pub enum ScheduleDescriptionAction {
474 StartWorkflow(ScheduleDescriptionStartWorkflowAction),
476}
477
478impl ScheduleDescriptionAction {
479 fn from_proto(
480 action: &schedule_proto::ScheduleAction,
481 data_converter: DataConverter,
482 ) -> Option<Self> {
483 match action.action.as_ref()? {
484 schedule_proto::schedule_action::Action::StartWorkflow(info) => {
485 Some(Self::StartWorkflow(
486 ScheduleDescriptionStartWorkflowAction::from_proto(info, data_converter),
487 ))
488 }
489 }
490 }
491}
492
493#[derive(Debug, Clone)]
495#[non_exhaustive]
496pub struct ScheduleDescriptionStartWorkflowAction {
497 workflow_type: String,
498 task_queue: String,
499 workflow_id: String,
500 input: Option<common_proto::Payloads>,
501 data_converter: DataConverter,
502}
503
504impl ScheduleDescriptionStartWorkflowAction {
505 fn from_proto(
506 info: &workflow_proto::NewWorkflowExecutionInfo,
507 data_converter: DataConverter,
508 ) -> Self {
509 Self {
510 workflow_type: info
511 .workflow_type
512 .as_ref()
513 .map(|t| t.name.clone())
514 .unwrap_or_default(),
515 task_queue: info
516 .task_queue
517 .as_ref()
518 .map(|t| t.name.clone())
519 .unwrap_or_default(),
520 workflow_id: info.workflow_id.clone(),
521 input: info.input.clone(),
522 data_converter,
523 }
524 }
525
526 pub fn workflow_type(&self) -> &str {
528 &self.workflow_type
529 }
530
531 pub fn task_queue(&self) -> &str {
533 &self.task_queue
534 }
535
536 pub fn workflow_id(&self) -> &str {
538 &self.workflow_id
539 }
540
541 pub async fn args<T: TemporalDeserializable + 'static>(
543 &self,
544 ) -> Result<Option<T>, PayloadConversionError> {
545 match &self.input {
546 Some(input) => self
547 .data_converter
548 .from_payloads(
549 &SerializationContextData::Workflow(WorkflowSerializationContext::new()),
550 input.payloads.clone(),
551 )
552 .await
553 .map(Some),
554 None => Ok(None),
555 }
556 }
557
558 pub fn raw_args(&self) -> Option<&[common_proto::Payload]> {
560 self.input.as_ref().map(|input| input.payloads.as_slice())
561 }
562}
563
564impl From<&schedule_proto::ScheduleActionResult> for ScheduleRecentAction {
565 fn from(a: &schedule_proto::ScheduleActionResult) -> Self {
566 let workflow_result = a
567 .start_workflow_result
568 .as_ref()
569 .expect("unsupported schedule action: start_workflow_result should be present");
570 ScheduleRecentAction {
571 schedule_time: a.schedule_time.as_ref().and_then(proto_ts_to_system_time),
572 actual_time: a.actual_time.as_ref().and_then(proto_ts_to_system_time),
573 workflow_id: workflow_result.workflow_id.clone(),
574 run_id: workflow_result.run_id.clone(),
575 }
576 }
577}
578
579#[derive(Debug, Clone)]
585pub struct ScheduleDescription {
586 raw: DescribeScheduleResponse,
587 data_converter: DataConverter,
588}
589
590impl ScheduleDescription {
591 pub(crate) fn new(
592 raw: DescribeScheduleResponse,
593 data_converter: DataConverter,
594 schedule_id: &str,
595 ) -> Result<Self, ScheduleError> {
596 let action = raw
597 .schedule
598 .as_ref()
599 .ok_or_else(|| Self::malformed_description_error(schedule_id, "missing schedule"))?
600 .action
601 .as_ref()
602 .ok_or_else(|| {
603 Self::malformed_description_error(schedule_id, "missing schedule action")
604 })?;
605 if action.action.is_none() {
606 return Err(Self::malformed_description_error(
607 schedule_id,
608 "missing schedule action variant",
609 ));
610 }
611 Ok(Self {
612 raw,
613 data_converter,
614 })
615 }
616
617 fn malformed_description_error(schedule_id: &str, reason: impl Into<String>) -> ScheduleError {
618 ScheduleError::MalformedDescription {
619 schedule_id: schedule_id.to_string(),
620 reason: reason.into(),
621 }
622 }
623
624 pub fn conflict_token(&self) -> &[u8] {
626 &self.raw.conflict_token
627 }
628
629 pub fn action(&self) -> ScheduleDescriptionAction {
631 let action = self
632 .raw
633 .schedule
634 .as_ref()
635 .expect("schedule description should contain schedule")
636 .action
637 .as_ref()
638 .expect("schedule description should contain action");
639 ScheduleDescriptionAction::from_proto(action, self.data_converter.clone())
640 .expect("schedule action should contain an action variant")
641 }
642
643 pub fn paused(&self) -> bool {
645 self.raw
646 .schedule
647 .as_ref()
648 .and_then(|s| s.state.as_ref())
649 .is_some_and(|st| st.paused)
650 }
651
652 pub fn note(&self) -> Option<&str> {
655 self.raw
656 .schedule
657 .as_ref()
658 .and_then(|s| s.state.as_ref())
659 .map(|st| st.notes.as_str())
660 .filter(|s| !s.is_empty())
661 }
662
663 pub fn action_count(&self) -> i64 {
665 self.info().map_or(0, |i| i.action_count)
666 }
667
668 pub fn missed_catchup_window(&self) -> i64 {
670 self.info().map_or(0, |i| i.missed_catchup_window)
671 }
672
673 pub fn overlap_skipped(&self) -> i64 {
675 self.info().map_or(0, |i| i.overlap_skipped)
676 }
677
678 pub fn recent_actions(&self) -> Vec<ScheduleRecentAction> {
680 self.info()
681 .map(|i| {
682 i.recent_actions
683 .iter()
684 .map(ScheduleRecentAction::from)
685 .collect()
686 })
687 .unwrap_or_default()
688 }
689
690 pub fn running_actions(&self) -> Vec<ScheduleRunningAction> {
692 self.info()
693 .map(|i| {
694 i.running_workflows
695 .iter()
696 .map(|w| ScheduleRunningAction {
697 workflow_id: w.workflow_id.clone(),
698 run_id: w.run_id.clone(),
699 })
700 .collect()
701 })
702 .unwrap_or_default()
703 }
704
705 pub fn future_action_times(&self) -> Vec<SystemTime> {
707 self.info()
708 .map(|i| {
709 i.future_action_times
710 .iter()
711 .filter_map(proto_ts_to_system_time)
712 .collect()
713 })
714 .unwrap_or_default()
715 }
716
717 pub fn create_time(&self) -> Option<SystemTime> {
719 self.info()
720 .and_then(|i| i.create_time.as_ref())
721 .and_then(proto_ts_to_system_time)
722 }
723
724 pub fn update_time(&self) -> Option<SystemTime> {
726 self.info()
727 .and_then(|i| i.update_time.as_ref())
728 .and_then(proto_ts_to_system_time)
729 }
730
731 pub fn memo(&self) -> crate::Memo {
733 crate::Memo::from_raw(
734 self.raw.memo.clone(),
735 self.data_converter.payload_converter().clone(),
736 SerializationContextData::Workflow(WorkflowSerializationContext::new()),
737 )
738 }
739
740 pub fn search_attributes(&self) -> SearchAttributes {
742 self.raw
743 .search_attributes
744 .as_ref()
745 .map(SearchAttributes::from_proto)
746 .unwrap_or_default()
747 }
748
749 pub fn raw(&self) -> &DescribeScheduleResponse {
751 &self.raw
752 }
753
754 pub fn into_raw(self) -> DescribeScheduleResponse {
756 self.raw
757 }
758
759 fn info(&self) -> Option<&schedule_proto::ScheduleInfo> {
760 self.raw.info.as_ref()
761 }
762
763 pub fn into_update(self) -> ScheduleUpdate {
768 ScheduleUpdate {
769 schedule: self.raw.schedule.unwrap_or_default(),
770 pending_action: None,
771 }
772 }
773}
774
775#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
777#[non_exhaustive]
778pub enum ScheduleOverlapPolicy {
779 #[default]
781 Unspecified,
782 Skip,
784 BufferOne,
786 BufferAll,
788 CancelOther,
790 TerminateOther,
792 AllowAll,
794}
795
796impl ScheduleOverlapPolicy {
797 pub(crate) fn to_proto(self) -> i32 {
798 match self {
799 Self::Unspecified => 0,
800 Self::Skip => 1,
801 Self::BufferOne => 2,
802 Self::BufferAll => 3,
803 Self::CancelOther => 4,
804 Self::TerminateOther => 5,
805 Self::AllowAll => 6,
806 }
807 }
808}
809
810#[derive(Debug, Clone, PartialEq, bon::Builder)]
812#[non_exhaustive]
813#[builder(start_fn = new)]
814pub struct ScheduleBackfill {
815 #[builder(start_fn)]
817 pub start_time: SystemTime,
818 #[builder(start_fn)]
820 pub end_time: SystemTime,
821 #[builder(default)]
823 pub overlap_policy: ScheduleOverlapPolicy,
824}
825
826#[derive(Debug, Clone)]
831pub struct ScheduleUpdate {
832 schedule: schedule_proto::Schedule,
833 pending_action: Option<ScheduleAction>,
834}
835
836impl ScheduleUpdate {
837 pub fn set_spec(&mut self, spec: ScheduleSpec) -> &mut Self {
839 self.schedule.spec = Some(spec.into_proto());
840 self
841 }
842
843 pub fn set_action(&mut self, action: ScheduleAction) -> &mut Self {
845 self.pending_action = Some(action);
846 self
847 }
848
849 pub fn set_paused(&mut self, paused: bool) -> &mut Self {
851 self.state_mut().paused = paused;
852 self
853 }
854
855 pub fn set_note(&mut self, note: impl Into<String>) -> &mut Self {
857 self.state_mut().notes = note.into();
858 self
859 }
860
861 pub fn set_overlap_policy(&mut self, policy: ScheduleOverlapPolicy) -> &mut Self {
863 self.policies_mut().overlap_policy = policy.to_proto();
864 self
865 }
866
867 pub fn set_catchup_window(&mut self, window: Duration) -> &mut Self {
870 self.policies_mut().catchup_window = window.try_into().ok();
871 self
872 }
873
874 pub fn set_pause_on_failure(&mut self, pause_on_failure: bool) -> &mut Self {
876 self.policies_mut().pause_on_failure = pause_on_failure;
877 self
878 }
879
880 pub fn set_keep_original_workflow_id(&mut self, keep: bool) -> &mut Self {
883 self.policies_mut().keep_original_workflow_id = keep;
884 self
885 }
886
887 pub fn set_remaining_actions(&mut self, count: Option<i64>) -> &mut Self {
890 let state = self.state_mut();
891 match count {
892 Some(n) => {
893 state.limited_actions = true;
894 state.remaining_actions = n;
895 }
896 None => {
897 state.limited_actions = false;
898 state.remaining_actions = 0;
899 }
900 }
901 self
902 }
903
904 pub fn raw(&self) -> &schedule_proto::Schedule {
906 &self.schedule
907 }
908
909 pub fn into_raw(self) -> schedule_proto::Schedule {
911 self.schedule
912 }
913
914 fn state_mut(&mut self) -> &mut schedule_proto::ScheduleState {
915 self.schedule.state.get_or_insert_with(Default::default)
916 }
917
918 fn policies_mut(&mut self) -> &mut schedule_proto::SchedulePolicies {
919 self.schedule.policies.get_or_insert_with(Default::default)
920 }
921}
922
923#[derive(Debug, Clone)]
929pub struct ScheduleSummary {
930 raw: schedule_proto::ScheduleListEntry,
931 data_converter: DataConverter,
932}
933
934impl ScheduleSummary {
935 pub fn schedule_id(&self) -> &str {
937 &self.raw.schedule_id
938 }
939
940 pub fn workflow_type(&self) -> Option<&str> {
942 self.info()
943 .and_then(|i| i.workflow_type.as_ref())
944 .map(|wt| wt.name.as_str())
945 }
946
947 pub fn note(&self) -> Option<&str> {
950 self.info()
951 .map(|i| i.notes.as_str())
952 .filter(|s| !s.is_empty())
953 }
954
955 pub fn paused(&self) -> bool {
957 self.info().is_some_and(|i| i.paused)
958 }
959
960 pub fn recent_actions(&self) -> Vec<ScheduleRecentAction> {
962 self.info()
963 .map(|i| {
964 i.recent_actions
965 .iter()
966 .map(ScheduleRecentAction::from)
967 .collect()
968 })
969 .unwrap_or_default()
970 }
971
972 pub fn future_action_times(&self) -> Vec<SystemTime> {
974 self.info()
975 .map(|i| {
976 i.future_action_times
977 .iter()
978 .filter_map(proto_ts_to_system_time)
979 .collect()
980 })
981 .unwrap_or_default()
982 }
983
984 pub fn memo(&self) -> crate::Memo {
986 crate::Memo::from_raw(
987 self.raw.memo.clone(),
988 self.data_converter.payload_converter().clone(),
989 SerializationContextData::Workflow(WorkflowSerializationContext::new()),
990 )
991 }
992
993 pub fn search_attributes(&self) -> SearchAttributes {
995 self.raw
996 .search_attributes
997 .as_ref()
998 .map(SearchAttributes::from_proto)
999 .unwrap_or_default()
1000 }
1001
1002 pub fn raw(&self) -> &schedule_proto::ScheduleListEntry {
1004 &self.raw
1005 }
1006
1007 pub fn into_raw(self) -> schedule_proto::ScheduleListEntry {
1009 self.raw
1010 }
1011
1012 fn info(&self) -> Option<&schedule_proto::ScheduleListInfo> {
1013 self.raw.info.as_ref()
1014 }
1015}
1016
1017impl From<schedule_proto::ScheduleListEntry> for ScheduleSummary {
1018 fn from(raw: schedule_proto::ScheduleListEntry) -> Self {
1019 Self::new(raw, DataConverter::default())
1020 }
1021}
1022
1023impl ScheduleSummary {
1024 fn new(raw: schedule_proto::ScheduleListEntry, data_converter: DataConverter) -> Self {
1025 Self {
1026 raw,
1027 data_converter,
1028 }
1029 }
1030}
1031
1032#[derive(Clone, derive_more::Debug)]
1036pub struct ScheduleHandle<CT> {
1037 #[debug(skip)]
1038 client: CT,
1039 namespace: String,
1040 schedule_id: String,
1041}
1042
1043impl<CT> ScheduleHandle<CT>
1044where
1045 CT: WorkflowService + NamespacedClient + Clone + Send + Sync,
1046{
1047 pub(crate) fn new(client: CT, namespace: String, schedule_id: String) -> Self {
1048 Self {
1049 client,
1050 namespace,
1051 schedule_id,
1052 }
1053 }
1054
1055 pub fn namespace(&self) -> &str {
1057 &self.namespace
1058 }
1059
1060 pub fn schedule_id(&self) -> &str {
1062 &self.schedule_id
1063 }
1064
1065 pub async fn describe(
1067 &self,
1068 rpc_options: RpcOptions,
1069 ) -> Result<ScheduleDescription, ScheduleError> {
1070 let output = interceptors::call_describe_schedule(
1071 self.client.client_interceptors(),
1072 DescribeScheduleInput {
1073 schedule_id: self.schedule_id.clone(),
1074 rpc_options,
1075 },
1076 Next::new({
1077 let handle = self.clone();
1078 move |input: DescribeScheduleInput| -> BoxFuture<
1079 '_,
1080 Result<DescribeScheduleOutput, ScheduleError>,
1081 > {
1082 Box::pin(async move {
1083 let response = handle
1084 .describe_raw(input.schedule_id, input.rpc_options)
1085 .await?;
1086 Ok(DescribeScheduleOutput::new(response))
1087 })
1088 }
1089 }),
1090 )
1091 .await?;
1092
1093 let mut resp = output.response;
1094 if let Some(memo) = resp.memo.as_mut() {
1095 decode_payloads(
1096 memo,
1097 self.client.data_converter().codec(),
1098 &SerializationContextData::Workflow(WorkflowSerializationContext::new()),
1099 )
1100 .await?;
1101 }
1102
1103 ScheduleDescription::new(
1104 resp,
1105 self.client.data_converter().clone(),
1106 &self.schedule_id,
1107 )
1108 }
1109
1110 pub async fn update(
1133 &self,
1134 updater: impl FnOnce(&mut ScheduleUpdate) + Send + 'static,
1135 rpc_options: RpcOptions,
1136 ) -> Result<(), ScheduleError> {
1137 interceptors::call_update_schedule(
1138 self.client.client_interceptors(),
1139 UpdateScheduleInput {
1140 schedule_id: self.schedule_id.clone(),
1141 rpc_options,
1142 },
1143 Next::new({
1144 let handle = self.clone();
1145 move |input: UpdateScheduleInput| -> BoxFuture<'_, Result<(), ScheduleError>> {
1146 Box::pin(async move {
1147 let mut response = handle
1148 .describe_raw(input.schedule_id.clone(), input.rpc_options.clone())
1149 .await?;
1150 decode_payloads(
1151 &mut response,
1152 handle.client.data_converter().codec(),
1153 &SerializationContextData::Workflow(WorkflowSerializationContext::new()),
1154 )
1155 .await?;
1156 let description = ScheduleDescription::new(
1157 response,
1158 handle.client.data_converter().clone(),
1159 &input.schedule_id,
1160 )?;
1161 let mut update = description.into_update();
1162 updater(&mut update);
1163 handle
1164 .send_update_raw(input.schedule_id, update, input.rpc_options)
1165 .await
1166 })
1167 }
1168 }),
1169 )
1170 .await
1171 }
1172
1173 pub async fn send_update(
1179 &self,
1180 update: ScheduleUpdate,
1181 rpc_options: RpcOptions,
1182 ) -> Result<(), ScheduleError> {
1183 interceptors::call_send_schedule_update(
1184 self.client.client_interceptors(),
1185 SendScheduleUpdateInput {
1186 schedule_id: self.schedule_id.clone(),
1187 update,
1188 rpc_options,
1189 },
1190 Next::new({
1191 let handle = self.clone();
1192 move |input: SendScheduleUpdateInput| -> BoxFuture<'_, Result<(), ScheduleError>> {
1193 Box::pin(async move {
1194 handle
1195 .send_update_raw(input.schedule_id, input.update, input.rpc_options)
1196 .await
1197 })
1198 }
1199 }),
1200 )
1201 .await
1202 }
1203
1204 async fn describe_raw(
1205 &self,
1206 schedule_id: String,
1207 rpc_options: RpcOptions,
1208 ) -> Result<DescribeScheduleResponse, ScheduleError> {
1209 let mut request = DescribeScheduleRequest {
1210 namespace: self.namespace.clone(),
1211 schedule_id,
1212 }
1213 .into_request();
1214 rpc_options.apply_to(&mut request);
1215 Ok(
1216 WorkflowService::describe_schedule(&mut self.client.clone(), request)
1217 .await?
1218 .into_inner(),
1219 )
1220 }
1221
1222 async fn send_update_raw(
1223 &self,
1224 schedule_id: String,
1225 mut update: ScheduleUpdate,
1226 rpc_options: RpcOptions,
1227 ) -> Result<(), ScheduleError> {
1228 if let Some(action) = update.pending_action.take() {
1229 update.schedule.action = Some(action.into_proto(self.client.data_converter()).await?);
1230 }
1231 let mut request = UpdateScheduleRequest {
1232 namespace: self.namespace.clone(),
1233 schedule_id,
1234 schedule: Some(update.schedule),
1235 identity: self.client.identity(),
1236 request_id: Uuid::new_v4().to_string(),
1237 ..Default::default()
1238 }
1239 .into_request();
1240 rpc_options.apply_to(&mut request);
1241 WorkflowService::update_schedule(&mut self.client.clone(), request).await?;
1242 Ok(())
1243 }
1244
1245 pub async fn delete(&self, options: DeleteScheduleOptions) -> Result<(), ScheduleError> {
1247 let rpc_options = options.rpc_options;
1248 interceptors::call_delete_schedule(
1249 self.client.client_interceptors(),
1250 DeleteScheduleInput {
1251 schedule_id: self.schedule_id.clone(),
1252 rpc_options,
1253 },
1254 Next::new({
1255 let mut client = self.client.clone();
1256 let namespace = self.namespace.clone();
1257 move |input: DeleteScheduleInput| -> BoxFuture<'_, Result<(), ScheduleError>> {
1258 Box::pin(async move {
1259 let mut request = DeleteScheduleRequest {
1260 namespace,
1261 schedule_id: input.schedule_id,
1262 identity: client.identity(),
1263 }
1264 .into_request();
1265 input.rpc_options.apply_to(&mut request);
1266 WorkflowService::delete_schedule(&mut client, request).await?;
1267 Ok(())
1268 })
1269 }
1270 }),
1271 )
1272 .await
1273 }
1274
1275 pub async fn pause(
1279 &self,
1280 note: Option<impl Into<String>>,
1281 options: PauseScheduleOptions,
1282 ) -> Result<(), ScheduleError> {
1283 let note = note.map_or_else(|| "Paused via Rust SDK".to_string(), |s| s.into());
1284 let rpc_options = options.rpc_options;
1285 interceptors::call_pause_schedule(
1286 self.client.client_interceptors(),
1287 PauseScheduleInput {
1288 schedule_id: self.schedule_id.clone(),
1289 note,
1290 rpc_options,
1291 },
1292 Next::new({
1293 let mut client = self.client.clone();
1294 let namespace = self.namespace.clone();
1295 move |input: PauseScheduleInput| -> BoxFuture<'_, Result<(), ScheduleError>> {
1296 Box::pin(async move {
1297 let mut request = PatchScheduleRequest {
1298 namespace,
1299 schedule_id: input.schedule_id,
1300 patch: Some(schedule_proto::SchedulePatch {
1301 pause: input.note,
1302 ..Default::default()
1303 }),
1304 identity: client.identity(),
1305 request_id: Uuid::new_v4().to_string(),
1306 }
1307 .into_request();
1308 input.rpc_options.apply_to(&mut request);
1309 WorkflowService::patch_schedule(&mut client, request).await?;
1310 Ok(())
1311 })
1312 }
1313 }),
1314 )
1315 .await
1316 }
1317
1318 pub async fn unpause(
1322 &self,
1323 note: Option<impl Into<String>>,
1324 options: UnpauseScheduleOptions,
1325 ) -> Result<(), ScheduleError> {
1326 let note = note.map_or_else(|| "Unpaused via Rust SDK".to_string(), |s| s.into());
1327 let rpc_options = options.rpc_options;
1328 interceptors::call_unpause_schedule(
1329 self.client.client_interceptors(),
1330 UnpauseScheduleInput {
1331 schedule_id: self.schedule_id.clone(),
1332 note,
1333 rpc_options,
1334 },
1335 Next::new({
1336 let mut client = self.client.clone();
1337 let namespace = self.namespace.clone();
1338 move |input: UnpauseScheduleInput| -> BoxFuture<'_, Result<(), ScheduleError>> {
1339 Box::pin(async move {
1340 let mut request = PatchScheduleRequest {
1341 namespace,
1342 schedule_id: input.schedule_id,
1343 patch: Some(schedule_proto::SchedulePatch {
1344 unpause: input.note,
1345 ..Default::default()
1346 }),
1347 identity: client.identity(),
1348 request_id: Uuid::new_v4().to_string(),
1349 }
1350 .into_request();
1351 input.rpc_options.apply_to(&mut request);
1352 WorkflowService::patch_schedule(&mut client, request).await?;
1353 Ok(())
1354 })
1355 }
1356 }),
1357 )
1358 .await
1359 }
1360
1361 pub async fn trigger(
1363 &self,
1364 overlap_policy: ScheduleOverlapPolicy,
1365 options: TriggerScheduleOptions,
1366 ) -> Result<(), ScheduleError> {
1367 let rpc_options = options.rpc_options;
1368 interceptors::call_trigger_schedule(
1369 self.client.client_interceptors(),
1370 TriggerScheduleInput {
1371 schedule_id: self.schedule_id.clone(),
1372 overlap_policy,
1373 rpc_options,
1374 },
1375 Next::new({
1376 let mut client = self.client.clone();
1377 let namespace = self.namespace.clone();
1378 move |input: TriggerScheduleInput| -> BoxFuture<'_, Result<(), ScheduleError>> {
1379 Box::pin(async move {
1380 let mut request = PatchScheduleRequest {
1381 namespace,
1382 schedule_id: input.schedule_id,
1383 patch: Some(schedule_proto::SchedulePatch {
1384 trigger_immediately: Some(
1385 schedule_proto::TriggerImmediatelyRequest {
1386 overlap_policy: input.overlap_policy.to_proto(),
1387 scheduled_time: None,
1388 },
1389 ),
1390 ..Default::default()
1391 }),
1392 identity: client.identity(),
1393 request_id: Uuid::new_v4().to_string(),
1394 }
1395 .into_request();
1396 input.rpc_options.apply_to(&mut request);
1397 WorkflowService::patch_schedule(&mut client, request).await?;
1398 Ok(())
1399 })
1400 }
1401 }),
1402 )
1403 .await
1404 }
1405
1406 pub async fn backfill(
1408 &self,
1409 backfills: impl IntoIterator<Item = ScheduleBackfill>,
1410 options: BackfillScheduleOptions,
1411 ) -> Result<(), ScheduleError> {
1412 let rpc_options = options.rpc_options;
1413 interceptors::call_backfill_schedule(
1414 self.client.client_interceptors(),
1415 BackfillScheduleInput {
1416 schedule_id: self.schedule_id.clone(),
1417 backfills: backfills.into_iter().collect(),
1418 rpc_options,
1419 },
1420 Next::new({
1421 let mut client = self.client.clone();
1422 let namespace = self.namespace.clone();
1423 move |input: BackfillScheduleInput| -> BoxFuture<'_, Result<(), ScheduleError>> {
1424 Box::pin(async move {
1425 let backfill_requests = input
1426 .backfills
1427 .into_iter()
1428 .map(|backfill| schedule_proto::BackfillRequest {
1429 start_time: Some(backfill.start_time.into()),
1430 end_time: Some(backfill.end_time.into()),
1431 overlap_policy: backfill.overlap_policy.to_proto(),
1432 })
1433 .collect();
1434 let mut request = PatchScheduleRequest {
1435 namespace,
1436 schedule_id: input.schedule_id,
1437 patch: Some(schedule_proto::SchedulePatch {
1438 backfill_request: backfill_requests,
1439 ..Default::default()
1440 }),
1441 identity: client.identity(),
1442 request_id: Uuid::new_v4().to_string(),
1443 }
1444 .into_request();
1445 input.rpc_options.apply_to(&mut request);
1446 WorkflowService::patch_schedule(&mut client, request).await?;
1447 Ok(())
1448 })
1449 }
1450 }),
1451 )
1452 .await
1453 }
1454}
1455
1456impl Client {
1458 pub async fn create_schedule(
1460 &self,
1461 schedule_id: impl Into<String>,
1462 opts: CreateScheduleOptions,
1463 ) -> Result<ScheduleHandle<Self>, ScheduleError> {
1464 let schedule_id = schedule_id.into();
1465 let namespace = self.namespace();
1466 let output = interceptors::call_create_schedule(
1467 self.client_interceptors(),
1468 CreateScheduleInput {
1469 schedule_id,
1470 options: opts,
1471 },
1472 Next::new({
1473 let mut client = self.clone();
1474 move |input: CreateScheduleInput| -> BoxFuture<
1475 '_,
1476 Result<CreateScheduleOutput, ScheduleError>,
1477 > {
1478 Box::pin(async move {
1479 let options = input.options;
1480 let initial_patch = options.trigger_immediately.then(|| {
1481 schedule_proto::SchedulePatch {
1482 trigger_immediately: Some(
1483 schedule_proto::TriggerImmediatelyRequest {
1484 overlap_policy: ScheduleOverlapPolicy::AllowAll.to_proto(),
1485 scheduled_time: None,
1486 },
1487 ),
1488 ..Default::default()
1489 }
1490 });
1491 let policies = (options.overlap_policy
1492 != ScheduleOverlapPolicy::Unspecified)
1493 .then(|| schedule_proto::SchedulePolicies {
1494 overlap_policy: options.overlap_policy.to_proto(),
1495 ..Default::default()
1496 });
1497 let schedule = schedule_proto::Schedule {
1498 spec: Some(options.spec.into_proto()),
1499 action: Some(
1500 options.action.into_proto(client.data_converter()).await?,
1501 ),
1502 policies,
1503 state: Some(schedule_proto::ScheduleState {
1504 paused: options.paused,
1505 notes: options.note,
1506 ..Default::default()
1507 }),
1508 };
1509 let mut request = CreateScheduleRequest {
1510 namespace: client.namespace(),
1511 schedule_id: input.schedule_id.clone(),
1512 schedule: Some(schedule),
1513 initial_patch,
1514 identity: client.identity(),
1515 request_id: Uuid::new_v4().to_string(),
1516 ..Default::default()
1517 }
1518 .into_request();
1519 options.rpc_options.apply_to(&mut request);
1520 WorkflowService::create_schedule(&mut client, request).await?;
1521 Ok(CreateScheduleOutput::new(input.schedule_id))
1522 })
1523 }
1524 }),
1525 )
1526 .await?;
1527 Ok(ScheduleHandle::new(
1528 self.clone(),
1529 namespace,
1530 output.schedule_id,
1531 ))
1532 }
1533
1534 pub fn get_schedule_handle(&self, schedule_id: impl Into<String>) -> ScheduleHandle<Self> {
1536 ScheduleHandle::new(self.clone(), self.namespace(), schedule_id.into())
1537 }
1538
1539 pub fn list_schedules(&self, opts: ListSchedulesOptions) -> ListSchedulesStream {
1542 list_schedules_stream(self.clone(), opts)
1543 }
1544}
1545
1546fn list_schedules_stream<CT>(client: CT, opts: ListSchedulesOptions) -> ListSchedulesStream
1547where
1548 CT: WorkflowService + NamespacedClient + Clone + Send + Sync + 'static,
1549{
1550 let namespace = client.namespace();
1551 let query = opts.query;
1552 let page_size = opts.maximum_page_size;
1553 let rpc_options = opts.rpc_options;
1554
1555 let stream = stream::unfold(
1556 (Vec::new(), VecDeque::new(), false),
1557 move |(next_page_token, mut buffer, exhausted)| {
1558 let client = client.clone();
1559 let namespace = namespace.clone();
1560 let query = query.clone();
1561 let rpc_options = rpc_options.clone();
1562
1563 async move {
1564 if let Some(item) = buffer.pop_front() {
1565 return Some((Ok(item), (next_page_token, buffer, exhausted)));
1566 } else if exhausted {
1567 return None;
1568 }
1569
1570 let response = interceptors::call_list_schedules_page(
1571 client.client_interceptors(),
1572 ListSchedulesPageInput {
1573 maximum_page_size: page_size,
1574 query,
1575 next_page_token: next_page_token.clone(),
1576 rpc_options,
1577 },
1578 Next::new({
1579 let mut rpc_client = client.clone();
1580 move |input: ListSchedulesPageInput| -> BoxFuture<
1581 '_,
1582 Result<ListSchedulesPageOutput, ScheduleError>,
1583 > {
1584 Box::pin(async move {
1585 let mut request = ListSchedulesRequest {
1586 namespace,
1587 maximum_page_size: input.maximum_page_size,
1588 next_page_token: input.next_page_token,
1589 query: input.query,
1590 }
1591 .into_request();
1592 input.rpc_options.apply_to(&mut request);
1593 let response =
1594 WorkflowService::list_schedules(&mut rpc_client, request)
1595 .await?
1596 .into_inner();
1597 Ok(ListSchedulesPageOutput::new(
1598 response.schedules,
1599 response.next_page_token,
1600 ))
1601 })
1602 }
1603 }),
1604 )
1605 .await;
1606
1607 match response {
1608 Ok(mut output) => {
1609 let new_exhausted = output.next_page_token.is_empty();
1610 let new_token = output.next_page_token;
1611
1612 let data_converter = client.data_converter().clone();
1613 for schedule in &mut output.schedules {
1614 if let Some(memo) = schedule.memo.as_mut()
1615 && let Err(err) = decode_payloads(
1616 memo,
1617 data_converter.codec(),
1618 &SerializationContextData::Workflow(
1619 WorkflowSerializationContext::new(),
1620 ),
1621 )
1622 .await
1623 {
1624 return Some((
1625 Err(ScheduleError::from(err)),
1626 (new_token, buffer, true),
1627 ));
1628 }
1629 }
1630 buffer = output
1631 .schedules
1632 .into_iter()
1633 .map(|raw| ScheduleSummary::new(raw, data_converter.clone()))
1634 .collect();
1635
1636 buffer
1637 .pop_front()
1638 .map(|item| (Ok(item), (new_token, buffer, new_exhausted)))
1639 }
1640 Err(e) => Some((Err(e), (next_page_token, buffer, true))),
1641 }
1642 }
1643 },
1644 );
1645
1646 ListSchedulesStream::new(Box::pin(stream))
1647}
1648
1649#[cfg(test)]
1650mod tests {
1651 use super::*;
1652 use crate::{
1653 ClientInterceptor, DescribeScheduleInput, DescribeScheduleOutput, NamespacedClient, Next,
1654 SendScheduleUpdateInput, UpdateScheduleInput,
1655 grpc::WorkflowService,
1656 test_helpers::{FailingCodec, XorCodec},
1657 };
1658 use futures_util::{FutureExt, StreamExt};
1659 use std::{
1660 collections::HashMap,
1661 sync::{
1662 Arc,
1663 atomic::{AtomicUsize, Ordering},
1664 },
1665 time::SystemTime,
1666 };
1667 use temporalio_common::{
1668 UntypedWorkflow,
1669 data_converters::{
1670 DataConverter, DefaultFailureConverter, MultiArgs2, PayloadConverter, RawValue,
1671 },
1672 protos::temporal::api::{
1673 common::v1::{
1674 Memo, Payload, Payloads, SearchAttributes,
1675 WorkflowExecution as ProtoWorkflowExecution, WorkflowType,
1676 },
1677 schedule::v1::{
1678 Schedule, ScheduleActionResult, ScheduleInfo, ScheduleListEntry, ScheduleListInfo,
1679 ScheduleSpec, ScheduleState,
1680 },
1681 taskqueue::v1::TaskQueue,
1682 workflow::v1::NewWorkflowExecutionInfo,
1683 workflowservice::v1::{
1684 DeleteScheduleResponse, DescribeScheduleResponse, ListSchedulesResponse,
1685 PatchScheduleResponse, UpdateScheduleResponse,
1686 },
1687 },
1688 };
1689 use tonic::{Request, Response};
1690
1691 fn data_converter_with_codec() -> DataConverter {
1692 DataConverter::new(
1693 PayloadConverter::default(),
1694 DefaultFailureConverter::default(),
1695 XorCodec,
1696 )
1697 }
1698
1699 #[derive(Default)]
1700 struct CapturedRequests {
1701 describe: AtomicUsize,
1702 update: AtomicUsize,
1703 delete: AtomicUsize,
1704 patch: AtomicUsize,
1705 list: AtomicUsize,
1706 }
1707
1708 #[derive(Clone)]
1709 struct MockScheduleClient {
1710 captured: Arc<CapturedRequests>,
1711 describe_response: DescribeScheduleResponse,
1712 list_response: ListSchedulesResponse,
1713 data_converter: DataConverter,
1714 should_error: bool,
1715 interceptors: Vec<Arc<dyn ClientInterceptor>>,
1716 }
1717
1718 impl Default for MockScheduleClient {
1719 fn default() -> Self {
1720 Self {
1721 captured: Arc::new(CapturedRequests::default()),
1722 describe_response: describe_response_with_start_workflow(None),
1723 list_response: ListSchedulesResponse::default(),
1724 data_converter: DataConverter::default(),
1725 should_error: false,
1726 interceptors: Vec::new(),
1727 }
1728 }
1729 }
1730
1731 impl NamespacedClient for MockScheduleClient {
1732 fn namespace(&self) -> String {
1733 "test-namespace".to_string()
1734 }
1735 fn identity(&self) -> String {
1736 "test-identity".to_string()
1737 }
1738 fn data_converter(&self) -> &DataConverter {
1739 &self.data_converter
1740 }
1741 fn client_interceptors(&self) -> &[Arc<dyn ClientInterceptor>] {
1742 &self.interceptors
1743 }
1744 }
1745
1746 #[derive(Default)]
1747 struct ScheduleHookCounts {
1748 describe: AtomicUsize,
1749 update: AtomicUsize,
1750 send_update: AtomicUsize,
1751 }
1752
1753 struct RecordingScheduleInterceptor {
1754 counts: Arc<ScheduleHookCounts>,
1755 }
1756
1757 impl ClientInterceptor for RecordingScheduleInterceptor {
1758 fn describe_schedule<'a>(
1759 &'a self,
1760 input: DescribeScheduleInput,
1761 next: Next<
1762 'a,
1763 DescribeScheduleInput,
1764 BoxFuture<'a, Result<DescribeScheduleOutput, ScheduleError>>,
1765 >,
1766 ) -> BoxFuture<'a, Result<DescribeScheduleOutput, ScheduleError>> {
1767 self.counts.describe.fetch_add(1, Ordering::SeqCst);
1768 next.run(input)
1769 }
1770
1771 fn update_schedule<'a>(
1772 &'a self,
1773 input: UpdateScheduleInput,
1774 next: Next<'a, UpdateScheduleInput, BoxFuture<'a, Result<(), ScheduleError>>>,
1775 ) -> BoxFuture<'a, Result<(), ScheduleError>> {
1776 self.counts.update.fetch_add(1, Ordering::SeqCst);
1777 next.run(input)
1778 }
1779
1780 fn send_schedule_update<'a>(
1781 &'a self,
1782 input: SendScheduleUpdateInput,
1783 next: Next<'a, SendScheduleUpdateInput, BoxFuture<'a, Result<(), ScheduleError>>>,
1784 ) -> BoxFuture<'a, Result<(), ScheduleError>> {
1785 self.counts.send_update.fetch_add(1, Ordering::SeqCst);
1786 next.run(input)
1787 }
1788 }
1789
1790 impl WorkflowService for MockScheduleClient {
1791 fn describe_schedule(
1792 &mut self,
1793 _request: Request<DescribeScheduleRequest>,
1794 ) -> futures_util::future::BoxFuture<
1795 '_,
1796 Result<Response<DescribeScheduleResponse>, tonic::Status>,
1797 > {
1798 self.captured.describe.fetch_add(1, Ordering::SeqCst);
1799 let resp = self.describe_response.clone();
1800 let should_error = self.should_error;
1801 async move {
1802 if should_error {
1803 Err(tonic::Status::not_found("schedule not found"))
1804 } else {
1805 Ok(Response::new(resp))
1806 }
1807 }
1808 .boxed()
1809 }
1810
1811 fn update_schedule(
1812 &mut self,
1813 _request: Request<UpdateScheduleRequest>,
1814 ) -> futures_util::future::BoxFuture<
1815 '_,
1816 Result<Response<UpdateScheduleResponse>, tonic::Status>,
1817 > {
1818 self.captured.update.fetch_add(1, Ordering::SeqCst);
1819 let should_error = self.should_error;
1820 async move {
1821 if should_error {
1822 Err(tonic::Status::internal("update failed"))
1823 } else {
1824 Ok(Response::new(UpdateScheduleResponse::default()))
1825 }
1826 }
1827 .boxed()
1828 }
1829
1830 fn delete_schedule(
1831 &mut self,
1832 _request: Request<DeleteScheduleRequest>,
1833 ) -> futures_util::future::BoxFuture<
1834 '_,
1835 Result<Response<DeleteScheduleResponse>, tonic::Status>,
1836 > {
1837 self.captured.delete.fetch_add(1, Ordering::SeqCst);
1838 let should_error = self.should_error;
1839 async move {
1840 if should_error {
1841 Err(tonic::Status::internal("delete failed"))
1842 } else {
1843 Ok(Response::new(DeleteScheduleResponse::default()))
1844 }
1845 }
1846 .boxed()
1847 }
1848
1849 fn patch_schedule(
1850 &mut self,
1851 _request: Request<PatchScheduleRequest>,
1852 ) -> futures_util::future::BoxFuture<
1853 '_,
1854 Result<Response<PatchScheduleResponse>, tonic::Status>,
1855 > {
1856 self.captured.patch.fetch_add(1, Ordering::SeqCst);
1857 let should_error = self.should_error;
1858 async move {
1859 if should_error {
1860 Err(tonic::Status::internal("patch failed"))
1861 } else {
1862 Ok(Response::new(PatchScheduleResponse::default()))
1863 }
1864 }
1865 .boxed()
1866 }
1867
1868 fn list_schedules(
1869 &mut self,
1870 _request: Request<ListSchedulesRequest>,
1871 ) -> futures_util::future::BoxFuture<
1872 '_,
1873 Result<Response<ListSchedulesResponse>, tonic::Status>,
1874 > {
1875 self.captured.list.fetch_add(1, Ordering::SeqCst);
1876 let response = self.list_response.clone();
1877 async move { Ok(Response::new(response)) }.boxed()
1878 }
1879 }
1880
1881 fn make_schedule_handle(client: MockScheduleClient) -> ScheduleHandle<MockScheduleClient> {
1882 ScheduleHandle::new(
1883 client,
1884 "test-namespace".to_string(),
1885 "test-schedule-id".to_string(),
1886 )
1887 }
1888
1889 fn describe_response_with_start_workflow(input: Option<Payloads>) -> DescribeScheduleResponse {
1890 DescribeScheduleResponse {
1891 schedule: Some(Schedule {
1892 action: Some(schedule_proto::ScheduleAction {
1893 action: Some(schedule_proto::schedule_action::Action::StartWorkflow(
1894 NewWorkflowExecutionInfo {
1895 workflow_id: "wf-id".to_string(),
1896 workflow_type: Some(WorkflowType {
1897 name: "MyWorkflow".to_string(),
1898 }),
1899 task_queue: Some(TaskQueue {
1900 name: "task-queue".to_string(),
1901 ..Default::default()
1902 }),
1903 input,
1904 ..Default::default()
1905 },
1906 )),
1907 }),
1908 ..Default::default()
1909 }),
1910 ..Default::default()
1911 }
1912 }
1913
1914 fn schedule_description_from_response(raw: DescribeScheduleResponse) -> ScheduleDescription {
1915 ScheduleDescription::new(raw, DataConverter::default(), "test-schedule-id").unwrap()
1916 }
1917
1918 fn describe_response_without_schedule() -> DescribeScheduleResponse {
1919 DescribeScheduleResponse::default()
1920 }
1921
1922 fn describe_response_without_action() -> DescribeScheduleResponse {
1923 DescribeScheduleResponse {
1924 schedule: Some(Schedule::default()),
1925 ..Default::default()
1926 }
1927 }
1928
1929 fn describe_response_without_action_variant() -> DescribeScheduleResponse {
1930 DescribeScheduleResponse {
1931 schedule: Some(Schedule {
1932 action: Some(schedule_proto::ScheduleAction::default()),
1933 ..Default::default()
1934 }),
1935 ..Default::default()
1936 }
1937 }
1938
1939 #[test]
1940 fn schedule_handle_exposes_namespace_and_id() {
1941 let handle = make_schedule_handle(MockScheduleClient::default());
1942 assert_eq!(handle.namespace(), "test-namespace");
1943 assert_eq!(handle.schedule_id(), "test-schedule-id");
1944 }
1945
1946 #[tokio::test]
1947 async fn schedule_describe_returns_response_fields() {
1948 let conflict_token = b"token-123".to_vec();
1949 let mut describe_response = describe_response_with_start_workflow(None);
1950 describe_response.info = Some(ScheduleInfo::default());
1951 describe_response.memo = Some(Memo {
1952 fields: Default::default(),
1953 });
1954 describe_response.search_attributes = Some(SearchAttributes {
1955 indexed_fields: Default::default(),
1956 });
1957 describe_response.conflict_token = conflict_token.clone();
1958
1959 let client = MockScheduleClient {
1960 describe_response,
1961 ..Default::default()
1962 };
1963
1964 let handle = make_schedule_handle(client.clone());
1965 let desc = handle.describe(RpcOptions::default()).await.unwrap();
1966
1967 assert_eq!(client.captured.describe.load(Ordering::SeqCst), 1);
1968 assert!(desc.raw().schedule.is_some());
1969 assert!(desc.raw().info.is_some());
1970 assert!(desc.raw().memo.is_some());
1971 assert!(desc.raw().search_attributes.is_some());
1972 assert!(desc.search_attributes().is_empty());
1973 assert_eq!(desc.conflict_token(), conflict_token);
1974 }
1975
1976 #[tokio::test]
1977 async fn schedule_description_exposes_typed_memo() {
1978 let data_converter = data_converter_with_codec();
1979 let memo_payload = data_converter
1980 .to_payload(
1981 &SerializationContextData::Workflow(WorkflowSerializationContext::new()),
1982 &"memo-value".to_owned(),
1983 )
1984 .await
1985 .unwrap();
1986 let mut describe_response = describe_response_with_start_workflow(None);
1987 describe_response.memo = Some(Memo {
1988 fields: HashMap::from([("memo-key".to_owned(), memo_payload)]),
1989 });
1990 let client = MockScheduleClient {
1991 describe_response,
1992 data_converter,
1993 ..Default::default()
1994 };
1995
1996 let description = make_schedule_handle(client)
1997 .describe(RpcOptions::default())
1998 .await
1999 .unwrap();
2000
2001 assert_eq!(
2002 description.memo().get::<String>("memo-key").unwrap(),
2003 Some("memo-value".to_owned())
2004 );
2005 }
2006
2007 #[tokio::test]
2008 async fn schedule_summary_exposes_typed_memo() {
2009 let data_converter = DataConverter::default();
2010 let memo_payload = data_converter
2011 .to_payload(
2012 &SerializationContextData::Workflow(WorkflowSerializationContext::new()),
2013 &"memo-value".to_owned(),
2014 )
2015 .await
2016 .unwrap();
2017 let summary = ScheduleSummary::new(
2018 ScheduleListEntry {
2019 schedule_id: "schedule-id".to_owned(),
2020 memo: Some(Memo {
2021 fields: HashMap::from([("memo-key".to_owned(), memo_payload)]),
2022 }),
2023 ..Default::default()
2024 },
2025 data_converter,
2026 );
2027
2028 assert_eq!(
2029 summary.memo().get::<String>("memo-key").unwrap(),
2030 Some("memo-value".to_owned())
2031 );
2032 }
2033
2034 #[tokio::test]
2035 async fn list_schedules_yields_codec_error_then_ends() {
2036 let client = MockScheduleClient {
2037 list_response: ListSchedulesResponse {
2038 schedules: vec![ScheduleListEntry {
2039 memo: Some(Memo {
2040 fields: HashMap::from([("memo-key".to_owned(), Payload::default())]),
2041 }),
2042 ..Default::default()
2043 }],
2044 next_page_token: b"next-page".to_vec(),
2045 },
2046 data_converter: DataConverter::new(
2047 PayloadConverter::default(),
2048 DefaultFailureConverter::default(),
2049 FailingCodec,
2050 ),
2051 ..Default::default()
2052 };
2053 let mut stream = list_schedules_stream(client.clone(), ListSchedulesOptions::default());
2054
2055 let err = stream.next().await.unwrap().unwrap_err();
2056
2057 assert!(matches!(err, ScheduleError::PayloadConversion(_)));
2058 assert!(stream.next().await.is_none());
2059 assert_eq!(client.captured.list.load(Ordering::SeqCst), 1);
2060 }
2061
2062 #[tokio::test]
2063 async fn schedule_update_describes_then_sends() {
2064 let client = MockScheduleClient::default();
2065 let handle = make_schedule_handle(client.clone());
2066
2067 handle
2068 .update(
2069 |u| {
2070 u.set_note("hi");
2071 },
2072 RpcOptions::default(),
2073 )
2074 .await
2075 .unwrap();
2076
2077 assert_eq!(client.captured.describe.load(Ordering::SeqCst), 1);
2078 assert_eq!(client.captured.update.load(Ordering::SeqCst), 1);
2079 }
2080
2081 #[tokio::test]
2082 async fn schedule_update_does_not_nest_describe_or_send_update_hooks() {
2083 let counts = Arc::new(ScheduleHookCounts::default());
2084 let client = MockScheduleClient {
2085 interceptors: vec![Arc::new(RecordingScheduleInterceptor {
2086 counts: counts.clone(),
2087 })],
2088 ..Default::default()
2089 };
2090 let handle = make_schedule_handle(client.clone());
2091
2092 handle
2093 .update(
2094 |update| {
2095 update.set_note("updated");
2096 },
2097 RpcOptions::default(),
2098 )
2099 .await
2100 .unwrap();
2101
2102 assert_eq!(counts.update.load(Ordering::SeqCst), 1);
2103 assert_eq!(counts.describe.load(Ordering::SeqCst), 0);
2104 assert_eq!(counts.send_update.load(Ordering::SeqCst), 0);
2105 assert_eq!(client.captured.describe.load(Ordering::SeqCst), 1);
2106 assert_eq!(client.captured.update.load(Ordering::SeqCst), 1);
2107 }
2108
2109 #[tokio::test]
2110 async fn schedule_multiple_updates_each_call_service() {
2111 let client = MockScheduleClient::default();
2112 let handle = make_schedule_handle(client.clone());
2113
2114 handle.update(|_| {}, RpcOptions::default()).await.unwrap();
2115 handle.update(|_| {}, RpcOptions::default()).await.unwrap();
2116
2117 assert_eq!(client.captured.update.load(Ordering::SeqCst), 2);
2118 }
2119
2120 #[tokio::test]
2121 async fn schedule_delete_calls_service() {
2122 let client = MockScheduleClient::default();
2123 let handle = make_schedule_handle(client.clone());
2124
2125 handle
2126 .delete(DeleteScheduleOptions::default())
2127 .await
2128 .unwrap();
2129
2130 assert_eq!(client.captured.delete.load(Ordering::SeqCst), 1);
2131 }
2132
2133 #[tokio::test]
2134 async fn schedule_pause_calls_patch() {
2135 let client = MockScheduleClient::default();
2136 let handle = make_schedule_handle(client.clone());
2137
2138 handle
2139 .pause(Some("taking a break"), PauseScheduleOptions::default())
2140 .await
2141 .unwrap();
2142
2143 assert_eq!(client.captured.patch.load(Ordering::SeqCst), 1);
2144 }
2145
2146 #[tokio::test]
2147 async fn schedule_pause_with_none_uses_default() {
2148 let client = MockScheduleClient::default();
2149 let handle = make_schedule_handle(client.clone());
2150
2151 handle
2152 .pause(None::<&str>, PauseScheduleOptions::default())
2153 .await
2154 .unwrap();
2155
2156 assert_eq!(client.captured.patch.load(Ordering::SeqCst), 1);
2157 }
2158
2159 #[tokio::test]
2160 async fn schedule_unpause_calls_patch() {
2161 let client = MockScheduleClient::default();
2162 let handle = make_schedule_handle(client.clone());
2163
2164 handle
2165 .unpause(Some("resuming work"), UnpauseScheduleOptions::default())
2166 .await
2167 .unwrap();
2168
2169 assert_eq!(client.captured.patch.load(Ordering::SeqCst), 1);
2170 }
2171
2172 #[tokio::test]
2173 async fn schedule_trigger_calls_patch() {
2174 let client = MockScheduleClient::default();
2175 let handle = make_schedule_handle(client.clone());
2176
2177 handle
2178 .trigger(
2179 ScheduleOverlapPolicy::Unspecified,
2180 TriggerScheduleOptions::default(),
2181 )
2182 .await
2183 .unwrap();
2184
2185 assert_eq!(client.captured.patch.load(Ordering::SeqCst), 1);
2186 }
2187
2188 #[tokio::test]
2189 async fn schedule_backfill_calls_patch() {
2190 let client = MockScheduleClient::default();
2191 let handle = make_schedule_handle(client.clone());
2192
2193 let now = SystemTime::now();
2194 handle
2195 .backfill(
2196 vec![
2197 ScheduleBackfill::new(now, now)
2198 .overlap_policy(ScheduleOverlapPolicy::Skip)
2199 .build(),
2200 ScheduleBackfill::new(now, now)
2201 .overlap_policy(ScheduleOverlapPolicy::BufferOne)
2202 .build(),
2203 ],
2204 BackfillScheduleOptions::default(),
2205 )
2206 .await
2207 .unwrap();
2208
2209 assert_eq!(client.captured.patch.load(Ordering::SeqCst), 1);
2210 }
2211
2212 #[tokio::test]
2213 async fn schedule_describe_propagates_rpc_errors() {
2214 let client = MockScheduleClient {
2215 should_error: true,
2216 ..Default::default()
2217 };
2218 let handle = make_schedule_handle(client);
2219
2220 let err = handle.describe(RpcOptions::default()).await.unwrap_err();
2221 assert!(
2222 matches!(err, ScheduleError::Rpc(_)),
2223 "expected Rpc variant, got: {err:?}"
2224 );
2225 assert!(err.to_string().contains("schedule not found"));
2226 }
2227
2228 #[tokio::test]
2229 async fn schedule_update_propagates_rpc_errors() {
2230 let client = MockScheduleClient {
2231 should_error: true,
2232 ..Default::default()
2233 };
2234 let handle = make_schedule_handle(client);
2235
2236 let err = handle
2237 .update(|_| {}, RpcOptions::default())
2238 .await
2239 .unwrap_err();
2240 assert!(matches!(err, ScheduleError::Rpc(_)));
2241 }
2242
2243 #[tokio::test]
2244 async fn schedule_delete_propagates_rpc_errors() {
2245 let client = MockScheduleClient {
2246 should_error: true,
2247 ..Default::default()
2248 };
2249 let handle = make_schedule_handle(client);
2250
2251 let err = handle
2252 .delete(DeleteScheduleOptions::default())
2253 .await
2254 .unwrap_err();
2255 assert!(matches!(err, ScheduleError::Rpc(_)));
2256 }
2257
2258 #[tokio::test]
2259 async fn schedule_patch_operations_propagate_rpc_errors() {
2260 let client = MockScheduleClient {
2261 should_error: true,
2262 ..Default::default()
2263 };
2264 let handle = make_schedule_handle(client);
2265
2266 assert!(
2267 handle
2268 .pause(Some(""), PauseScheduleOptions::default())
2269 .await
2270 .is_err()
2271 );
2272 assert!(
2273 handle
2274 .unpause(Some(""), UnpauseScheduleOptions::default())
2275 .await
2276 .is_err()
2277 );
2278 assert!(
2279 handle
2280 .trigger(Default::default(), TriggerScheduleOptions::default())
2281 .await
2282 .is_err()
2283 );
2284 assert!(
2285 handle
2286 .backfill(vec![], BackfillScheduleOptions::default())
2287 .await
2288 .is_err()
2289 );
2290 }
2291
2292 #[tokio::test]
2293 async fn schedule_all_patch_operations_call_service() {
2294 let client = MockScheduleClient::default();
2295 let handle = make_schedule_handle(client.clone());
2296
2297 handle
2298 .pause(Some("p"), PauseScheduleOptions::default())
2299 .await
2300 .unwrap();
2301 handle
2302 .unpause(Some("u"), UnpauseScheduleOptions::default())
2303 .await
2304 .unwrap();
2305 handle
2306 .trigger(Default::default(), TriggerScheduleOptions::default())
2307 .await
2308 .unwrap();
2309 handle
2310 .backfill(vec![], BackfillScheduleOptions::default())
2311 .await
2312 .unwrap();
2313
2314 assert_eq!(client.captured.patch.load(Ordering::SeqCst), 4);
2315 }
2316
2317 #[tokio::test]
2318 async fn schedule_describe_accessors_with_populated_fields() {
2319 let mut describe_response = describe_response_with_start_workflow(None);
2320 let schedule = describe_response.schedule.as_mut().unwrap();
2321 schedule.spec = Some(ScheduleSpec {
2322 timezone_name: "US/Eastern".to_string(),
2323 ..Default::default()
2324 });
2325 schedule.state = Some(ScheduleState {
2326 paused: true,
2327 notes: "maintenance window".to_string(),
2328 ..Default::default()
2329 });
2330 describe_response.info = Some(ScheduleInfo {
2331 action_count: 42,
2332 missed_catchup_window: 3,
2333 overlap_skipped: 5,
2334 recent_actions: vec![ScheduleActionResult {
2335 start_workflow_result: Some(ProtoWorkflowExecution {
2336 workflow_id: "ra-wf".to_string(),
2337 run_id: "ra-run".to_string(),
2338 }),
2339 ..Default::default()
2340 }],
2341 running_workflows: vec![ProtoWorkflowExecution {
2342 workflow_id: "wf-1".to_string(),
2343 run_id: "run-1".to_string(),
2344 }],
2345 create_time: Some(prost_types::Timestamp {
2346 seconds: 1_700_000_000,
2347 nanos: 0,
2348 }),
2349 update_time: Some(prost_types::Timestamp {
2350 seconds: 1_700_001_000,
2351 nanos: 0,
2352 }),
2353 future_action_times: vec![prost_types::Timestamp {
2354 seconds: 1_700_002_000,
2355 nanos: 0,
2356 }],
2357 ..Default::default()
2358 });
2359 describe_response.conflict_token = b"tok".to_vec();
2360
2361 let client = MockScheduleClient {
2362 describe_response,
2363 ..Default::default()
2364 };
2365
2366 let handle = make_schedule_handle(client);
2367 let desc = handle.describe(RpcOptions::default()).await.unwrap();
2368
2369 assert!(desc.paused());
2370 assert_eq!(desc.note(), Some("maintenance window"));
2371 assert_eq!(desc.action_count(), 42);
2372 assert_eq!(desc.missed_catchup_window(), 3);
2373 assert_eq!(desc.overlap_skipped(), 5);
2374
2375 assert_eq!(desc.recent_actions().len(), 1);
2376 assert_eq!(
2377 desc.running_actions(),
2378 vec![ScheduleRunningAction {
2379 workflow_id: "wf-1".to_string(),
2380 run_id: "run-1".to_string(),
2381 }]
2382 );
2383
2384 assert_eq!(desc.future_action_times().len(), 1);
2385 assert!(desc.create_time().is_some());
2386 assert!(desc.update_time().is_some());
2387 }
2388
2389 #[tokio::test]
2390 async fn schedule_describe_defaults_when_nested_fields_are_none() {
2391 let client = MockScheduleClient::default();
2392
2393 let handle = make_schedule_handle(client);
2394 let desc = handle.describe(RpcOptions::default()).await.unwrap();
2395
2396 assert!(!desc.paused());
2397 assert_eq!(desc.note(), None);
2398 assert_eq!(desc.action_count(), 0);
2399 assert_eq!(desc.missed_catchup_window(), 0);
2400 assert_eq!(desc.overlap_skipped(), 0);
2401 assert!(desc.recent_actions().is_empty());
2402 assert!(desc.running_actions().is_empty());
2403 assert!(desc.future_action_times().is_empty());
2404 assert!(desc.create_time().is_none());
2405 assert!(desc.update_time().is_none());
2406 assert!(desc.conflict_token().is_empty());
2407 }
2408
2409 #[tokio::test]
2410 async fn schedule_note_returns_none_for_empty_string() {
2411 let mut describe_response = describe_response_with_start_workflow(None);
2412 describe_response.schedule.as_mut().unwrap().state = Some(ScheduleState {
2413 notes: String::new(),
2414 ..Default::default()
2415 });
2416
2417 let client = MockScheduleClient {
2418 describe_response,
2419 ..Default::default()
2420 };
2421
2422 let handle = make_schedule_handle(client);
2423 let desc = handle.describe(RpcOptions::default()).await.unwrap();
2424 assert_eq!(desc.note(), None);
2425 }
2426
2427 #[test]
2428 fn schedule_summary_note_returns_none_for_empty_string() {
2429 let entry = ScheduleListEntry {
2430 schedule_id: "s".to_string(),
2431 info: Some(ScheduleListInfo {
2432 notes: String::new(),
2433 ..Default::default()
2434 }),
2435 ..Default::default()
2436 };
2437 let summary = ScheduleSummary::from(entry);
2438 assert_eq!(summary.note(), None);
2439 }
2440
2441 #[test]
2442 fn schedule_summary_accessors() {
2443 let entry = ScheduleListEntry {
2444 schedule_id: "sched-1".to_string(),
2445 memo: Some(Memo {
2446 fields: Default::default(),
2447 }),
2448 search_attributes: Some(SearchAttributes {
2449 indexed_fields: Default::default(),
2450 }),
2451 info: Some(ScheduleListInfo {
2452 spec: Some(ScheduleSpec::default()),
2453 workflow_type: Some(WorkflowType {
2454 name: "MyWorkflow".to_string(),
2455 }),
2456 notes: "some note".to_string(),
2457 paused: true,
2458 recent_actions: vec![ScheduleActionResult {
2459 start_workflow_result: Some(ProtoWorkflowExecution {
2460 workflow_id: "ra-wf".to_string(),
2461 run_id: "ra-run".to_string(),
2462 }),
2463 ..Default::default()
2464 }],
2465 future_action_times: vec![prost_types::Timestamp {
2466 seconds: 1_700_000_000,
2467 nanos: 0,
2468 }],
2469 state_size_bytes: 0,
2470 }),
2471 };
2472
2473 let summary = ScheduleSummary::from(entry);
2474 assert_eq!(summary.schedule_id(), "sched-1");
2475 assert!(summary.raw().memo.is_some());
2476 assert!(summary.raw().search_attributes.is_some());
2477 assert!(summary.search_attributes().is_empty());
2478 assert_eq!(summary.workflow_type(), Some("MyWorkflow"));
2479 assert_eq!(summary.note(), Some("some note"));
2480 assert!(summary.paused());
2481 assert_eq!(summary.recent_actions().len(), 1);
2482 assert_eq!(summary.future_action_times().len(), 1);
2483 }
2484
2485 #[test]
2486 fn schedule_summary_defaults_when_info_is_none() {
2487 let entry = ScheduleListEntry {
2488 schedule_id: "sched-2".to_string(),
2489 ..Default::default()
2490 };
2491
2492 let summary = ScheduleSummary::from(entry);
2493 assert_eq!(summary.schedule_id(), "sched-2");
2494 assert!(summary.raw().memo.is_none());
2495 assert!(summary.raw().search_attributes.is_none());
2496 assert!(summary.search_attributes().is_empty());
2497 assert_eq!(summary.workflow_type(), None);
2498 assert_eq!(summary.note(), None);
2499 assert!(!summary.paused());
2500 assert!(summary.recent_actions().is_empty());
2501 assert!(summary.future_action_times().is_empty());
2502 }
2503
2504 #[test]
2505 fn schedule_description_raw_round_trip() {
2506 let mut resp = describe_response_with_start_workflow(None);
2507 resp.conflict_token = b"ct".to_vec();
2508
2509 let desc = schedule_description_from_response(resp.clone());
2510 assert_eq!(desc.raw().conflict_token, b"ct");
2511 let recovered = desc.into_raw();
2512 assert_eq!(recovered.conflict_token, resp.conflict_token);
2513 assert!(recovered.schedule.is_some());
2514 }
2515
2516 #[tokio::test]
2517 async fn schedule_description_action_decodes_start_workflow_args() {
2518 let data_converter = DataConverter::default();
2519 let expected = MultiArgs2("hello".to_string(), 42i32);
2520 let payloads = data_converter
2521 .to_payloads(
2522 &SerializationContextData::Workflow(WorkflowSerializationContext::new()),
2523 &expected,
2524 )
2525 .await
2526 .unwrap();
2527 let desc = ScheduleDescription::new(
2528 describe_response_with_start_workflow(Some(Payloads { payloads })),
2529 data_converter,
2530 "test-schedule-id",
2531 )
2532 .unwrap();
2533
2534 let ScheduleDescriptionAction::StartWorkflow(action) = desc.action();
2535
2536 assert_eq!(action.workflow_type(), "MyWorkflow");
2537 assert_eq!(action.task_queue(), "task-queue");
2538 assert_eq!(action.workflow_id(), "wf-id");
2539 let decoded: MultiArgs2<String, i32> = action.args().await.unwrap().unwrap();
2540 assert_eq!(decoded, expected);
2541 }
2542
2543 #[tokio::test]
2544 async fn schedule_description_start_workflow_args_returns_none_without_input() {
2545 let desc = schedule_description_from_response(describe_response_with_start_workflow(None));
2546 let ScheduleDescriptionAction::StartWorkflow(action) = desc.action();
2547
2548 let decoded: Option<String> = action.args().await.unwrap();
2549 assert_eq!(decoded, None);
2550 }
2551
2552 #[tokio::test]
2553 async fn schedule_description_start_workflow_args_propagates_decode_errors() {
2554 let data_converter = DataConverter::default();
2555 let expected: String = "not-an-int".to_string();
2556 let payloads = data_converter
2557 .to_payloads(
2558 &SerializationContextData::Workflow(WorkflowSerializationContext::new()),
2559 &expected,
2560 )
2561 .await
2562 .unwrap();
2563 let desc = schedule_description_from_response(describe_response_with_start_workflow(Some(
2564 Payloads { payloads },
2565 )));
2566 let ScheduleDescriptionAction::StartWorkflow(action) = desc.action();
2567
2568 let err = action.args::<i32>().await.unwrap_err();
2569 assert!(matches!(err, PayloadConversionError::EncodingError(_)));
2570 }
2571
2572 #[rstest::rstest]
2573 #[case::schedule(describe_response_without_schedule(), "missing schedule")]
2574 #[case::action(describe_response_without_action(), "missing schedule action")]
2575 #[case::action_variant(
2576 describe_response_without_action_variant(),
2577 "missing schedule action variant"
2578 )]
2579 #[tokio::test]
2580 async fn schedule_describe_errors_when_required_field_is_missing(
2581 #[case] describe_response: DescribeScheduleResponse,
2582 #[case] reason: &str,
2583 ) {
2584 let client = MockScheduleClient {
2585 describe_response,
2586 ..Default::default()
2587 };
2588 let handle = make_schedule_handle(client);
2589
2590 let err = handle.describe(RpcOptions::default()).await.unwrap_err();
2591 assert_eq!(
2592 err.to_string(),
2593 format!("Malformed schedule description for schedule ID 'test-schedule-id': {reason}")
2594 );
2595 }
2596
2597 #[test]
2598 fn schedule_summary_raw_round_trip() {
2599 let entry = ScheduleListEntry {
2600 schedule_id: "rt-1".to_string(),
2601 ..Default::default()
2602 };
2603 let summary = ScheduleSummary::from(entry.clone());
2604 assert_eq!(summary.raw().schedule_id, "rt-1");
2605 let recovered = summary.into_raw();
2606 assert_eq!(recovered.schedule_id, entry.schedule_id);
2607 }
2608
2609 #[test]
2610 fn schedule_into_update_preserves_schedule() {
2611 let mut resp = describe_response_with_start_workflow(None);
2612 resp.schedule.as_mut().unwrap().state = Some(ScheduleState {
2613 notes: "my notes".to_string(),
2614 ..Default::default()
2615 });
2616 let desc = schedule_description_from_response(resp);
2617 let update = desc.into_update();
2618
2619 assert_eq!(update.raw().state.as_ref().unwrap().notes, "my notes");
2620 }
2621
2622 #[test]
2623 fn schedule_update_setters_are_chainable() {
2624 let desc = schedule_description_from_response(describe_response_with_start_workflow(None));
2625 let mut update = desc.into_update();
2626 update.set_note("chained").set_paused(true);
2627 assert_eq!(update.raw().state.as_ref().unwrap().notes, "chained");
2628 assert!(update.raw().state.as_ref().unwrap().paused);
2629 }
2630
2631 #[test]
2632 fn schedule_recent_action_from_proto_with_timestamps() {
2633 let ts = prost_types::Timestamp {
2634 seconds: 1_700_000_000,
2635 nanos: 0,
2636 };
2637 let proto = ScheduleActionResult {
2638 schedule_time: Some(ts),
2639 actual_time: Some(ts),
2640 start_workflow_result: Some(ProtoWorkflowExecution {
2641 workflow_id: "wf-abc".to_string(),
2642 run_id: "run-xyz".to_string(),
2643 }),
2644 ..Default::default()
2645 };
2646
2647 let action = ScheduleRecentAction::from(&proto);
2648
2649 assert!(action.schedule_time.is_some());
2650 assert!(action.actual_time.is_some());
2651 assert_eq!(action.workflow_id, "wf-abc");
2652 assert_eq!(action.run_id, "run-xyz");
2653 }
2654
2655 #[test]
2656 #[should_panic(expected = "unsupported schedule action")]
2657 fn schedule_recent_action_panics_without_workflow_result() {
2658 let _ = ScheduleRecentAction::from(&ScheduleActionResult::default());
2659 }
2660
2661 #[test]
2662 fn schedule_overlap_policy_default_is_unspecified() {
2663 assert_eq!(
2664 ScheduleOverlapPolicy::default(),
2665 ScheduleOverlapPolicy::Unspecified
2666 );
2667 }
2668
2669 #[tokio::test]
2670 async fn schedule_action_start_workflow_with_input_into_proto() {
2671 let payload = Payload {
2672 metadata: [("encoding".to_string(), b"json/plain".to_vec())]
2673 .into_iter()
2674 .collect(),
2675 data: b"42".to_vec(),
2676 ..Default::default()
2677 };
2678 let action = ScheduleAction::start_workflow(
2679 UntypedWorkflow::new("MyWorkflow"),
2680 RawValue::new(vec![payload.clone()]),
2681 "my-queue",
2682 "my-wf-id",
2683 );
2684 let proto = action.into_proto(&DataConverter::default()).await.unwrap();
2685 #[allow(irrefutable_let_patterns)]
2686 let schedule_proto::schedule_action::Action::StartWorkflow(wf_info) = proto.action.unwrap()
2687 else {
2688 panic!("expected StartWorkflow action")
2689 };
2690 assert_eq!(wf_info.input.unwrap().payloads, vec![payload]);
2691 }
2692}