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}