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}