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}