Skip to main content

assay_workflow/
tasks.rs

1//! Workflow-task dispatch and timers.
2
3use anyhow::Result;
4
5use crate::ctx::{timestamp_now, WorkflowCtx};
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(
95                        workflow_id,
96                        seq,
97                        name,
98                        input.as_deref(),
99                        queue,
100                        opts,
101                    )
102                    .await?;
103                }
104                "CancelWorkflow" => {
105                    // Worker acknowledged a cancellation — finalise.
106                    self.finalise_cancellation(workflow_id).await?;
107                }
108                "WaitForSignal" => {
109                    // No engine-side state to write — the workflow has paused
110                    // and will be re-dispatched when a matching signal arrives.
111                    // Releasing the lease (below) is enough; record the wait
112                    // intent for the dashboard / debugging.
113                    //
114                    // When the command carries `timer_seq`, the wait is paired
115                    // with a `ScheduleTimer` yielded in the same batch — the
116                    // worker uses the timer_seq to pick the winner on replay
117                    // (signal vs timeout). The engine stores the pairing on
118                    // the event for observability only.
119                    let signal_name =
120                        cmd.get("name").and_then(|v| v.as_str()).unwrap_or("?");
121                    let timer_seq = cmd.get("timer_seq").and_then(|v| v.as_i64());
122                    let payload = match timer_seq {
123                        Some(ts) => serde_json::json!({
124                            "signal": signal_name,
125                            "timer_seq": ts,
126                        }),
127                        None => serde_json::json!({ "signal": signal_name }),
128                    };
129                    let event_seq =
130                        self.store.get_event_count(workflow_id).await? as i32 + 1;
131                    self.store
132                        .append_event(&WorkflowEvent {
133                            id: None,
134                            workflow_id: workflow_id.to_string(),
135                            seq: event_seq,
136                            event_type: "WorkflowAwaitingSignal".to_string(),
137                            payload: Some(payload.to_string()),
138                            timestamp: timestamp_now(),
139                        })
140                        .await?;
141                }
142                "StartChildWorkflow" => {
143                    let workflow_type = cmd
144                        .get("workflow_type")
145                        .and_then(|v| v.as_str())
146                        .unwrap_or("");
147                    let child_id =
148                        cmd.get("workflow_id").and_then(|v| v.as_str()).unwrap_or("");
149                    let task_queue = cmd
150                        .get("task_queue")
151                        .and_then(|v| v.as_str())
152                        .unwrap_or("default");
153                    let input = cmd.get("input").map(|v| v.to_string());
154                    // Determine the namespace from the parent workflow
155                    let namespace = self
156                        .store
157                        .get_workflow(workflow_id)
158                        .await?
159                        .map(|wf| wf.namespace)
160                        .unwrap_or_else(|| "main".to_string());
161
162                    // Idempotent: if a workflow with this id already exists,
163                    // skip creation (deterministic replay calls this command
164                    // for the same child id on every re-run until the parent
165                    // has the ChildWorkflowCompleted event).
166                    if self.store.get_workflow(child_id).await?.is_none() {
167                        self.start_child_workflow(
168                            &namespace,
169                            workflow_id,
170                            workflow_type,
171                            child_id,
172                            input.as_deref(),
173                            task_queue,
174                        )
175                        .await?;
176                        // Make the child immediately dispatchable so a worker
177                        // picks it up; emit WorkflowNeedsDispatch via the bus.
178                        self.mark_and_emit_needs_dispatch(child_id).await?;
179                    }
180                }
181                "RecordSideEffect" => {
182                    let seq = cmd.get("seq").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
183                    let name = cmd.get("name").and_then(|v| v.as_str()).unwrap_or("");
184                    let value =
185                        cmd.get("value").cloned().unwrap_or(serde_json::Value::Null);
186                    let event_seq =
187                        self.store.get_event_count(workflow_id).await? as i32 + 1;
188                    self.store
189                        .append_event(&WorkflowEvent {
190                            id: None,
191                            workflow_id: workflow_id.to_string(),
192                            seq: event_seq,
193                            event_type: "SideEffectRecorded".to_string(),
194                            payload: Some(
195                                serde_json::json!({
196                                    "side_effect_seq": seq,
197                                    "name": name,
198                                    "value": value,
199                                })
200                                .to_string(),
201                            ),
202                            timestamp: timestamp_now(),
203                        })
204                        .await?;
205                    // Side effects don't trigger anything external — the
206                    // workflow needs to immediately continue so it picks
207                    // up the cached value on next replay.
208                    self.mark_and_emit_needs_dispatch(workflow_id).await?;
209                }
210                "ScheduleTimer" => {
211                    let seq = cmd.get("seq").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
212                    let duration = cmd
213                        .get("duration_secs")
214                        .and_then(|v| v.as_f64())
215                        .unwrap_or(0.0);
216                    self.schedule_timer(workflow_id, seq, duration).await?;
217                }
218                "UpsertSearchAttributes" => {
219                    // Merge the patch object into the workflow's stored
220                    // search_attributes. Workflow code can call this from
221                    // `ctx:upsert_search_attributes(...)` to surface live
222                    // progress / tenant / env tags that downstream callers
223                    // can filter on via the list endpoint.
224                    let patch = cmd
225                        .get("patch")
226                        .cloned()
227                        .unwrap_or(serde_json::Value::Object(Default::default()));
228                    self.store
229                        .upsert_search_attributes(workflow_id, &patch.to_string())
230                        .await?;
231                }
232                "ContinueAsNew" => {
233                    // Close out the current run and start a new one with the
234                    // same type / namespace / queue under a fresh id. Input
235                    // may be any JSON value; it's serialised and becomes the
236                    // new run's `input`. Called from workflow code via
237                    // `ctx:continue_as_new(input)` to reset event history
238                    // when a handler would otherwise loop forever.
239                    let input = cmd.get("input").map(|v| v.to_string());
240                    self.continue_as_new(workflow_id, input.as_deref(), None)
241                        .await?;
242                }
243                "RecordSnapshot" => {
244                    // Persist the workflow's current query-handler state. Each
245                    // snapshot is keyed by the current event seq so the latest
246                    // is easy to retrieve via `get_latest_snapshot`. Runs on
247                    // every worker replay, which is fine — `create_snapshot`
248                    // is an insert, so each replay adds a new row reflecting
249                    // the state at that point in history.
250                    let state = cmd
251                        .get("state")
252                        .cloned()
253                        .unwrap_or(serde_json::Value::Null);
254                    let event_seq = self.store.get_event_count(workflow_id).await? as i32;
255                    self.store
256                        .create_snapshot(workflow_id, event_seq, &state.to_string())
257                        .await?;
258                }
259                "CompleteWorkflow" => {
260                    let result = cmd.get("result").map(|v| v.to_string());
261                    self.complete_workflow(workflow_id, result.as_deref()).await?;
262                }
263                "FailWorkflow" => {
264                    let error = cmd
265                        .get("error")
266                        .and_then(|v| v.as_str())
267                        .unwrap_or("workflow handler raised an error");
268                    self.fail_workflow(workflow_id, error).await?;
269                }
270                other => {
271                    tracing::warn!("submit_workflow_commands: unknown command type {other:?}");
272                }
273            }
274        }
275
276        self.store
277            .release_workflow_task(workflow_id, worker_id)
278            .await?;
279        Ok(())
280    }
281
282    /// Schedule a durable timer for a workflow.
283    ///
284    /// Idempotent on `(workflow_id, seq)` — a workflow that yields the same
285    /// `ScheduleTimer{seq=N}` on retry will reuse the existing timer, not
286    /// schedule a second one. This is the timer counterpart to
287    /// `schedule_activity`'s replay-safe behaviour.
288    ///
289    /// On the first call:
290    /// - inserts a row in `workflow_timers` with `fire_at = now + duration`
291    /// - appends a `TimerScheduled` event so the worker can replay and
292    ///   know it's been scheduled (otherwise replays would yield it again)
293    pub async fn schedule_timer(
294        &self,
295        workflow_id: &str,
296        seq: i32,
297        duration_secs: f64,
298    ) -> Result<WorkflowTimer> {
299        if let Some(existing) = self
300            .store
301            .get_timer_by_workflow_seq(workflow_id, seq)
302            .await?
303        {
304            return Ok(existing);
305        }
306
307        let now = timestamp_now();
308        let mut timer = WorkflowTimer {
309            id: None,
310            workflow_id: workflow_id.to_string(),
311            seq,
312            fire_at: now + duration_secs,
313            fired: false,
314        };
315        let id = self.store.create_timer(&timer).await?;
316        timer.id = Some(id);
317
318        let event_seq = self.store.get_event_count(workflow_id).await? as i32 + 1;
319        self.store
320            .append_event(&WorkflowEvent {
321                id: None,
322                workflow_id: workflow_id.to_string(),
323                seq: event_seq,
324                event_type: "TimerScheduled".to_string(),
325                payload: Some(
326                    serde_json::json!({
327                        "timer_id": id,
328                        "timer_seq": seq,
329                        "fire_at": timer.fire_at,
330                        "duration_secs": duration_secs,
331                    })
332                    .to_string(),
333                ),
334                timestamp: now,
335            })
336            .await?;
337
338        Ok(timer)
339    }
340}