Skip to main content

tmprl_client/ops/
mutate.rs

1//! The operations that change a cluster.
2//!
3//! Everything else in this crate reads. These write, and deleting a workflow or a schedule
4//! cannot be undone, so nothing here decides *whether* to act, the confirmation in
5//! `tmprl-core` does that, and the reducer only reaches this module once the reader has said
6//! yes.
7//!
8//! Every request carries an `identity`. Temporal records it on the resulting history event,
9//! so a workflow terminated from tmprl says so in its own history rather than appearing to
10//! have stopped on its own.
11
12use temporalio_client::tonic::Request;
13use temporalio_common::protos::temporal::api::{
14    common::v1::{Payload as ProtoPayload, Payloads, WorkflowExecution, WorkflowType},
15    enums::v1::UpdateWorkflowExecutionLifecycleStage,
16    schedule::v1::{
17        BackfillRequest, Schedule, ScheduleAction, SchedulePatch, ScheduleSpec,
18        TriggerImmediatelyRequest, schedule_action::Action as ScheduleActionKind,
19    },
20    taskqueue::v1::TaskQueue,
21    update::v1::{Input as UpdateInput, Meta as UpdateMeta, Request as UpdateRequest, WaitPolicy},
22    workflow::v1::NewWorkflowExecutionInfo,
23    workflowservice::v1::{
24        CreateScheduleRequest, DeleteScheduleRequest, DeleteWorkflowExecutionRequest,
25        PatchScheduleRequest, RequestCancelWorkflowExecutionRequest, ResetWorkflowExecutionRequest,
26        SignalWorkflowExecutionRequest, TerminateWorkflowExecutionRequest,
27        UpdateWorkflowExecutionRequest,
28    },
29};
30use tmprl_core::mutation::Mutation;
31
32use super::OpError;
33use crate::Conn;
34
35/// What tmprl calls itself on the events it causes.
36fn identity() -> String {
37    format!(
38        "tmprl@{}",
39        std::env::var("USER").unwrap_or_else(|_| "unknown".into())
40    )
41}
42
43/// A fresh request id.
44///
45/// `ResetWorkflowExecution` rejects a request without one ("RequestId is not set on request").
46/// The others accept one, where it makes a retried call idempotent rather than doubling the
47/// effect.
48fn request_id() -> String {
49    uuid::Uuid::new_v4().to_string()
50}
51
52/// Epoch millis as a protobuf timestamp.
53fn timestamp(ms: i64) -> prost_wkt_types::Timestamp {
54    prost_wkt_types::Timestamp {
55        seconds: ms.div_euclid(1_000),
56        nanos: (ms.rem_euclid(1_000) * 1_000_000) as i32,
57    }
58}
59
60fn execution(workflow_id: &str, run_id: &str) -> Option<WorkflowExecution> {
61    Some(WorkflowExecution {
62        workflow_id: workflow_id.to_string(),
63        run_id: run_id.to_string(),
64    })
65}
66
67impl Conn {
68    /// Carry out a confirmed mutation.
69    ///
70    /// One entry point rather than four, so the reducer has exactly one place that writes and
71    /// the audit log has exactly one thing to wrap.
72    pub async fn mutate(&self, m: &Mutation) -> Result<(), OpError> {
73        match m {
74            Mutation::Cancel {
75                namespace,
76                workflow_id,
77                run_id,
78            } => {
79                self.wf()
80                    .request_cancel_workflow_execution(Request::new(
81                        RequestCancelWorkflowExecutionRequest {
82                            namespace: namespace.clone(),
83                            workflow_execution: execution(workflow_id, run_id),
84                            identity: identity(),
85                            request_id: request_id(),
86                            ..Default::default()
87                        },
88                    ))
89                    .await
90                    .map_err(|s| OpError::rpc("RequestCancelWorkflowExecution", s))?;
91            }
92
93            Mutation::Terminate {
94                namespace,
95                workflow_id,
96                run_id,
97                reason,
98            } => {
99                self.wf()
100                    .terminate_workflow_execution(Request::new(TerminateWorkflowExecutionRequest {
101                        namespace: namespace.clone(),
102                        workflow_execution: execution(workflow_id, run_id),
103                        reason: reason.clone(),
104                        identity: identity(),
105                        ..Default::default()
106                    }))
107                    .await
108                    .map_err(|s| OpError::rpc("TerminateWorkflowExecution", s))?;
109            }
110
111            Mutation::Signal {
112                namespace,
113                workflow_id,
114                run_id,
115                name,
116                input,
117            } => {
118                self.wf()
119                    .signal_workflow_execution(Request::new(SignalWorkflowExecutionRequest {
120                        namespace: namespace.clone(),
121                        workflow_execution: execution(workflow_id, run_id),
122                        signal_name: name.clone(),
123                        input: input.as_deref().map(json_payload),
124                        identity: identity(),
125                        request_id: request_id(),
126                        ..Default::default()
127                    }))
128                    .await
129                    .map_err(|s| OpError::rpc("SignalWorkflowExecution", s))?;
130            }
131
132            Mutation::Delete {
133                namespace,
134                workflow_id,
135                run_id,
136            } => {
137                self.wf()
138                    .delete_workflow_execution(Request::new(DeleteWorkflowExecutionRequest {
139                        namespace: namespace.clone(),
140                        workflow_execution: execution(workflow_id, run_id),
141                    }))
142                    .await
143                    .map_err(|s| OpError::rpc("DeleteWorkflowExecution", s))?;
144            }
145
146            Mutation::Reset {
147                namespace,
148                workflow_id,
149                run_id,
150                event_id,
151                reason,
152            } => {
153                self.wf()
154                    .reset_workflow_execution(Request::new(ResetWorkflowExecutionRequest {
155                        namespace: namespace.clone(),
156                        workflow_execution: execution(workflow_id, run_id),
157                        reason: reason.clone(),
158                        // Already resolved to a completed workflow task; the server rejects
159                        // anything else.
160                        workflow_task_finish_event_id: *event_id,
161                        // Excluding nothing keeps signals sent after the reset point.
162                        // Replaces the deprecated `reset_reapply_type`.
163                        reset_reapply_exclude_types: Vec::new(),
164                        identity: identity(),
165                        request_id: request_id(),
166                        ..Default::default()
167                    }))
168                    .await
169                    .map_err(|s| OpError::rpc("ResetWorkflowExecution", s))?;
170            }
171
172            Mutation::Update {
173                namespace,
174                workflow_id,
175                run_id,
176                name,
177                input,
178            } => {
179                let resp = self
180                    .wf()
181                    .update_workflow_execution(Request::new(UpdateWorkflowExecutionRequest {
182                        namespace: namespace.clone(),
183                        workflow_execution: execution(workflow_id, run_id),
184                        // Wait for the outcome rather than for acceptance, so the reported
185                        // result is the workflow's answer and not just "it was allowed in".
186                        wait_policy: Some(WaitPolicy {
187                            lifecycle_stage: UpdateWorkflowExecutionLifecycleStage::Completed
188                                as i32,
189                        }),
190                        request: Some(UpdateRequest {
191                            request_id: request_id(),
192                            meta: Some(UpdateMeta {
193                                update_id: request_id(),
194                                identity: identity(),
195                            }),
196                            input: Some(UpdateInput {
197                                name: name.clone(),
198                                args: input.as_deref().map(json_payload),
199                                ..Default::default()
200                            }),
201                            completion_callbacks: Vec::new(),
202                            links: Vec::new(),
203                        }),
204                        ..Default::default()
205                    }))
206                    .await
207                    .map_err(|s| OpError::rpc("UpdateWorkflowExecution", s))?
208                    .into_inner();
209
210                // An update can be *accepted* and then rejected by the workflow itself. That
211                // is a failure of the thing the user asked for, so it is reported as one
212                // rather than as a success that quietly did nothing.
213                if let Some(outcome) = resp.outcome
214                    && let Some(
215                        temporalio_common::protos::temporal::api::update::v1::outcome::Value::Failure(f),
216                    ) = outcome.value
217                {
218                    return Err(OpError::Rpc {
219                        operation: "UpdateWorkflowExecution",
220                        code: "Rejected".into(),
221                        message: f.message,
222                    });
223                }
224            }
225
226            Mutation::PauseSchedule {
227                namespace,
228                schedule_id,
229                paused,
230            } => {
231                // The reason lands in the schedule's own notes, so a paused schedule says
232                // why it is paused rather than merely that it is.
233                let note = format!("{} from tmprl", if *paused { "paused" } else { "resumed" });
234                self.wf()
235                    .patch_schedule(Request::new(PatchScheduleRequest {
236                        namespace: namespace.clone(),
237                        schedule_id: schedule_id.clone(),
238                        patch: Some(SchedulePatch {
239                            pause: if *paused { note.clone() } else { String::new() },
240                            unpause: if *paused { String::new() } else { note },
241                            ..Default::default()
242                        }),
243                        identity: identity(),
244                        request_id: request_id(),
245                    }))
246                    .await
247                    .map_err(|s| OpError::rpc("PatchSchedule", s))?;
248            }
249
250            Mutation::TriggerSchedule {
251                namespace,
252                schedule_id,
253            } => {
254                self.wf()
255                    .patch_schedule(Request::new(PatchScheduleRequest {
256                        namespace: namespace.clone(),
257                        schedule_id: schedule_id.clone(),
258                        patch: Some(SchedulePatch {
259                            trigger_immediately: Some(TriggerImmediatelyRequest {
260                                // Unspecified leaves the schedule's own overlap policy in
261                                // charge, which is what the reader configured.
262                                overlap_policy: 0,
263                                // None means now, which is what "trigger" asks for.
264                                scheduled_time: None,
265                            }),
266                            ..Default::default()
267                        }),
268                        identity: identity(),
269                        request_id: request_id(),
270                    }))
271                    .await
272                    .map_err(|s| OpError::rpc("PatchSchedule", s))?;
273            }
274
275            Mutation::CreateSchedule {
276                namespace,
277                schedule_id,
278                workflow_id,
279                workflow_type,
280                task_queue,
281                spec,
282                input,
283            } => {
284                self.wf()
285                    .create_schedule(Request::new(CreateScheduleRequest {
286                        namespace: namespace.clone(),
287                        schedule_id: schedule_id.clone(),
288                        schedule: Some(Schedule {
289                            // The cron string is sent as typed. The server parses it, and it
290                            // accepts `@every 1h` as well as five-field cron, so validating
291                            // here would only reject specs the server would have taken.
292                            spec: Some(ScheduleSpec {
293                                cron_string: vec![spec.clone()],
294                                ..Default::default()
295                            }),
296                            action: Some(ScheduleAction {
297                                action: Some(ScheduleActionKind::StartWorkflow(
298                                    NewWorkflowExecutionInfo {
299                                        workflow_id: workflow_id.clone(),
300                                        workflow_type: Some(WorkflowType {
301                                            name: workflow_type.clone(),
302                                        }),
303                                        task_queue: Some(TaskQueue {
304                                            name: task_queue.clone(),
305                                            ..Default::default()
306                                        }),
307                                        input: input.as_deref().map(json_payload),
308                                        ..Default::default()
309                                    },
310                                )),
311                            }),
312                            ..Default::default()
313                        }),
314                        identity: identity(),
315                        request_id: request_id(),
316                        ..Default::default()
317                    }))
318                    .await
319                    .map_err(|s| OpError::rpc("CreateSchedule", s))?;
320            }
321
322            Mutation::BackfillSchedule {
323                namespace,
324                schedule_id,
325                range,
326                overlap,
327            } => {
328                self.wf()
329                    .patch_schedule(Request::new(PatchScheduleRequest {
330                        namespace: namespace.clone(),
331                        schedule_id: schedule_id.clone(),
332                        patch: Some(SchedulePatch {
333                            backfill_request: vec![BackfillRequest {
334                                start_time: Some(timestamp(range.start_ms)),
335                                end_time: Some(timestamp(range.end_ms)),
336                                overlap_policy: overlap.code(),
337                            }],
338                            ..Default::default()
339                        }),
340                        identity: identity(),
341                        request_id: request_id(),
342                    }))
343                    .await
344                    .map_err(|s| OpError::rpc("PatchSchedule", s))?;
345            }
346
347            Mutation::DeleteSchedule {
348                namespace,
349                schedule_id,
350            } => {
351                self.wf()
352                    .delete_schedule(Request::new(DeleteScheduleRequest {
353                        namespace: namespace.clone(),
354                        schedule_id: schedule_id.clone(),
355                        identity: identity(),
356                    }))
357                    .await
358                    .map_err(|s| OpError::rpc("DeleteSchedule", s))?;
359            }
360        }
361        Ok(())
362    }
363}
364
365/// A signal argument, encoded the way an SDK would send it.
366///
367/// `json/plain` with the text as typed. The confirmation shows the same string next to
368/// `--input`, so what the CLI would send and what tmprl sends are the same bytes.
369fn json_payload(input: &str) -> Payloads {
370    Payloads {
371        payloads: vec![ProtoPayload {
372            metadata: [("encoding".to_string(), b"json/plain".to_vec())]
373                .into_iter()
374                .collect(),
375            data: input.as_bytes().to_vec(),
376            external_payloads: Vec::new(),
377        }],
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn a_signal_argument_is_sent_as_json_plain() {
387        let p = json_payload(r#"{"a":1}"#);
388        assert_eq!(p.payloads.len(), 1);
389        assert_eq!(p.payloads[0].data, br#"{"a":1}"#);
390        assert_eq!(
391            p.payloads[0].metadata.get("encoding").map(|v| v.as_slice()),
392            Some(&b"json/plain"[..])
393        );
394    }
395
396    #[test]
397    fn tmprl_names_itself_on_what_it_causes() {
398        // A workflow terminated from here should say so in its own history rather than
399        // appearing to have stopped on its own.
400        assert!(identity().starts_with("tmprl@"));
401    }
402
403    #[test]
404    fn an_execution_carries_both_ids() {
405        let e = execution("w", "r").unwrap();
406        assert_eq!(e.workflow_id, "w");
407        assert_eq!(e.run_id, "r");
408    }
409}