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