Skip to main content

assay_workflow/
tasks.rs

1//! Workflow-task dispatch and timers.
2
3use anyhow::Result;
4
5use crate::ctx::{WorkflowCtx, timestamp_now};
6use crate::events::WorkflowBusEvent;
7use crate::store::WorkflowStore;
8use crate::types::*;
9
10impl<S: WorkflowStore> WorkflowCtx<S> {
11    /// Claim a dispatchable workflow task on a queue. Returns the workflow
12    /// record + full event history so the worker can replay the handler
13    /// deterministically. Atomic — multiple workers polling the same queue
14    /// will each get a different task or None.
15    pub async fn claim_workflow_task(
16        &self,
17        task_queue: &str,
18        worker_id: &str,
19    ) -> Result<Option<(WorkflowRecord, Vec<WorkflowEvent>)>> {
20        let Some(mut wf) = self
21            .store
22            .claim_workflow_task(task_queue, worker_id)
23            .await?
24        else {
25            return Ok(None);
26        };
27        // Once a worker is processing the workflow it's RUNNING — even if
28        // it ultimately just yields and pauses on a signal/timer. PENDING
29        // means "no worker has touched this yet."
30        if wf.status == "PENDING" {
31            self.store
32                .update_workflow_status(&wf.id, WorkflowStatus::Running, None, None)
33                .await?;
34            wf.status = "RUNNING".to_string();
35            // Live-update the dashboard so the row flips PENDING →
36            // RUNNING without requiring F5. Expected lifecycle for
37            // durable workflows is to start PENDING and advance once
38            // a worker claims, so this emit completes the loop.
39            self.emit(
40                &wf.namespace,
41                WorkflowBusEvent::WorkflowRunning {
42                    workflow_id: wf.id.clone(),
43                },
44            )
45            .await;
46        }
47        let history = self.store.list_events(&wf.id).await?;
48        Ok(Some((wf, history)))
49    }
50
51    /// Submit a worker's batch of commands for a workflow it claimed.
52    /// Each command produces durable events / rows transactionally and
53    /// the dispatch lease is released on return.
54    ///
55    /// Supported command types:
56    /// - `ScheduleActivity` { seq, name, task_queue, input?, max_attempts?, ... }
57    /// - `CompleteWorkflow` { result }
58    /// - `FailWorkflow`     { error }
59    pub async fn submit_workflow_commands(
60        &self,
61        workflow_id: &str,
62        worker_id: &str,
63        commands: &[serde_json::Value],
64    ) -> Result<()> {
65        for cmd in commands {
66            let cmd_type = cmd.get("type").and_then(|v| v.as_str()).unwrap_or("");
67            match cmd_type {
68                "ScheduleActivity" => {
69                    let seq = cmd.get("seq").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
70                    let name = cmd.get("name").and_then(|v| v.as_str()).unwrap_or("");
71                    let queue = cmd
72                        .get("task_queue")
73                        .and_then(|v| v.as_str())
74                        .unwrap_or("default");
75                    let input = cmd.get("input").map(|v| v.to_string());
76                    let opts = ScheduleActivityOpts {
77                        max_attempts: cmd
78                            .get("max_attempts")
79                            .and_then(|v| v.as_i64())
80                            .map(|n| n as i32),
81                        initial_interval_secs: cmd
82                            .get("initial_interval_secs")
83                            .and_then(|v| v.as_f64()),
84                        backoff_coefficient: cmd
85                            .get("backoff_coefficient")
86                            .and_then(|v| v.as_f64()),
87                        start_to_close_secs: cmd
88                            .get("start_to_close_secs")
89                            .and_then(|v| v.as_f64()),
90                        heartbeat_timeout_secs: cmd
91                            .get("heartbeat_timeout_secs")
92                            .and_then(|v| v.as_f64()),
93                    };
94                    self.schedule_activity(workflow_id, seq, name, input.as_deref(), queue, opts)
95                        .await?;
96                }
97                "CancelWorkflow" => {
98                    // Worker acknowledged a cancellation — finalise.
99                    self.finalise_cancellation(workflow_id).await?;
100                }
101                "WaitForSignal" => {
102                    // No engine-side state to write — the workflow has paused
103                    // and will be re-dispatched when a matching signal arrives.
104                    // Releasing the lease (below) is enough; record the wait
105                    // intent for the dashboard / debugging.
106                    //
107                    // When the command carries `timer_seq`, the wait is paired
108                    // with a `ScheduleTimer` yielded in the same batch — the
109                    // worker uses the timer_seq to pick the winner on replay
110                    // (signal vs timeout). The engine stores the pairing on
111                    // the event for observability only.
112                    let signal_name = cmd.get("name").and_then(|v| v.as_str()).unwrap_or("?");
113                    let timer_seq = cmd.get("timer_seq").and_then(|v| v.as_i64());
114                    let payload = match timer_seq {
115                        Some(ts) => serde_json::json!({
116                            "signal": signal_name,
117                            "timer_seq": ts,
118                        }),
119                        None => serde_json::json!({ "signal": signal_name }),
120                    };
121                    let event_seq = self.store.get_event_count(workflow_id).await? as i32 + 1;
122                    self.store
123                        .append_event(&WorkflowEvent {
124                            id: None,
125                            workflow_id: workflow_id.to_string(),
126                            seq: event_seq,
127                            event_type: "WorkflowAwaitingSignal".to_string(),
128                            payload: Some(payload.to_string()),
129                            timestamp: timestamp_now(),
130                        })
131                        .await?;
132                }
133                "StartChildWorkflow" => {
134                    let workflow_type = cmd
135                        .get("workflow_type")
136                        .and_then(|v| v.as_str())
137                        .unwrap_or("");
138                    let child_id = cmd
139                        .get("workflow_id")
140                        .and_then(|v| v.as_str())
141                        .unwrap_or("");
142                    let task_queue = cmd
143                        .get("task_queue")
144                        .and_then(|v| v.as_str())
145                        .unwrap_or("default");
146                    let input = cmd.get("input").map(|v| v.to_string());
147                    // Determine the namespace from the parent workflow
148                    let namespace = self
149                        .store
150                        .get_workflow(workflow_id)
151                        .await?
152                        .map(|wf| wf.namespace)
153                        .unwrap_or_else(|| "main".to_string());
154
155                    // Idempotent: if a workflow with this id already exists,
156                    // skip creation (deterministic replay calls this command
157                    // for the same child id on every re-run until the parent
158                    // has the ChildWorkflowCompleted event).
159                    if self.store.get_workflow(child_id).await?.is_none() {
160                        self.start_child_workflow(
161                            &namespace,
162                            workflow_id,
163                            workflow_type,
164                            child_id,
165                            input.as_deref(),
166                            task_queue,
167                        )
168                        .await?;
169                        // Make the child immediately dispatchable so a worker
170                        // picks it up; emit WorkflowNeedsDispatch via the bus.
171                        self.mark_and_emit_needs_dispatch(child_id).await?;
172                    }
173                }
174                "RecordSideEffect" => {
175                    let seq = cmd.get("seq").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
176                    let name = cmd.get("name").and_then(|v| v.as_str()).unwrap_or("");
177                    let value = cmd.get("value").cloned().unwrap_or(serde_json::Value::Null);
178                    let event_seq = self.store.get_event_count(workflow_id).await? as i32 + 1;
179                    self.store
180                        .append_event(&WorkflowEvent {
181                            id: None,
182                            workflow_id: workflow_id.to_string(),
183                            seq: event_seq,
184                            event_type: "SideEffectRecorded".to_string(),
185                            payload: Some(
186                                serde_json::json!({
187                                    "side_effect_seq": seq,
188                                    "name": name,
189                                    "value": value,
190                                })
191                                .to_string(),
192                            ),
193                            timestamp: timestamp_now(),
194                        })
195                        .await?;
196                    // Side effects don't trigger anything external — the
197                    // workflow needs to immediately continue so it picks
198                    // up the cached value on next replay.
199                    self.mark_and_emit_needs_dispatch(workflow_id).await?;
200                }
201                "ScheduleTimer" => {
202                    let seq = cmd.get("seq").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
203                    let duration = cmd
204                        .get("duration_secs")
205                        .and_then(|v| v.as_f64())
206                        .unwrap_or(0.0);
207                    self.schedule_timer(workflow_id, seq, duration).await?;
208                }
209                "UpsertSearchAttributes" => {
210                    // Merge the patch object into the workflow's stored
211                    // search_attributes. Workflow code can call this from
212                    // `ctx:upsert_search_attributes(...)` to surface live
213                    // progress / tenant / env tags that downstream callers
214                    // can filter on via the list endpoint.
215                    let patch = cmd
216                        .get("patch")
217                        .cloned()
218                        .unwrap_or(serde_json::Value::Object(Default::default()));
219                    self.store
220                        .upsert_search_attributes(workflow_id, &patch.to_string())
221                        .await?;
222                }
223                "ContinueAsNew" => {
224                    // Close out the current run and start a new one with the
225                    // same type / namespace / queue under a fresh id. Input
226                    // may be any JSON value; it's serialised and becomes the
227                    // new run's `input`. Called from workflow code via
228                    // `ctx:continue_as_new(input)` to reset event history
229                    // when a handler would otherwise loop forever.
230                    let input = cmd.get("input").map(|v| v.to_string());
231                    self.continue_as_new(workflow_id, input.as_deref(), None)
232                        .await?;
233                }
234                "RecordSnapshot" => {
235                    // Persist the workflow's current query-handler state. Each
236                    // snapshot is keyed by the current event seq so the latest
237                    // is easy to retrieve via `get_latest_snapshot`. Runs on
238                    // every worker replay, which is fine — `create_snapshot`
239                    // is an insert, so each replay adds a new row reflecting
240                    // the state at that point in history.
241                    let state = cmd.get("state").cloned().unwrap_or(serde_json::Value::Null);
242                    let event_seq = self.store.get_event_count(workflow_id).await? as i32;
243                    self.store
244                        .create_snapshot(workflow_id, event_seq, &state.to_string())
245                        .await?;
246                }
247                "CompleteWorkflow" => {
248                    let result = cmd.get("result").map(|v| v.to_string());
249                    self.complete_workflow(workflow_id, result.as_deref())
250                        .await?;
251                }
252                "FailWorkflow" => {
253                    let error = cmd
254                        .get("error")
255                        .and_then(|v| v.as_str())
256                        .unwrap_or("workflow handler raised an error");
257                    self.fail_workflow(workflow_id, error).await?;
258                }
259                other => {
260                    tracing::warn!("submit_workflow_commands: unknown command type {other:?}");
261                }
262            }
263        }
264
265        self.store
266            .release_workflow_task(workflow_id, worker_id)
267            .await?;
268        Ok(())
269    }
270
271    /// Schedule a durable timer for a workflow.
272    ///
273    /// Idempotent on `(workflow_id, seq)` — a workflow that yields the same
274    /// `ScheduleTimer{seq=N}` on retry will reuse the existing timer, not
275    /// schedule a second one. This is the timer counterpart to
276    /// `schedule_activity`'s replay-safe behaviour.
277    ///
278    /// On the first call:
279    /// - inserts a row in `workflow_timers` with `fire_at = now + duration`
280    /// - appends a `TimerScheduled` event so the worker can replay and
281    ///   know it's been scheduled (otherwise replays would yield it again)
282    pub async fn schedule_timer(
283        &self,
284        workflow_id: &str,
285        seq: i32,
286        duration_secs: f64,
287    ) -> Result<WorkflowTimer> {
288        if let Some(existing) = self
289            .store
290            .get_timer_by_workflow_seq(workflow_id, seq)
291            .await?
292        {
293            return Ok(existing);
294        }
295
296        let now = timestamp_now();
297        let mut timer = WorkflowTimer {
298            id: None,
299            workflow_id: workflow_id.to_string(),
300            seq,
301            fire_at: now + duration_secs,
302            fired: false,
303        };
304        let id = self.store.create_timer(&timer).await?;
305        timer.id = Some(id);
306
307        let event_seq = self.store.get_event_count(workflow_id).await? as i32 + 1;
308        self.store
309            .append_event(&WorkflowEvent {
310                id: None,
311                workflow_id: workflow_id.to_string(),
312                seq: event_seq,
313                event_type: "TimerScheduled".to_string(),
314                payload: Some(
315                    serde_json::json!({
316                        "timer_id": id,
317                        "timer_seq": seq,
318                        "fire_at": timer.fire_at,
319                        "duration_secs": duration_secs,
320                    })
321                    .to_string(),
322                ),
323                timestamp: now,
324            })
325            .await?;
326
327        Ok(timer)
328    }
329}