Skip to main content

assay_workflow/
activities.rs

1//! Activity operations and side effects.
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    /// Schedule an activity within a workflow.
12    ///
13    /// Idempotent on `(workflow_id, seq)` — if an activity with this sequence
14    /// number already exists for the workflow, returns its id without
15    /// creating a duplicate row or duplicate `ActivityScheduled` event. This
16    /// is essential for deterministic replay: a worker can re-run the
17    /// workflow function and call `schedule_activity(seq=1, ...)` repeatedly
18    /// without producing side effects.
19    ///
20    /// On the first call for a `seq`:
21    /// - inserts a row in `workflow_activities` with status `PENDING`
22    /// - appends an `ActivityScheduled` event to the workflow event log
23    /// - if the workflow is still `PENDING`, transitions it to `RUNNING`
24    pub async fn schedule_activity(
25        &self,
26        workflow_id: &str,
27        seq: i32,
28        name: &str,
29        input: Option<&str>,
30        task_queue: &str,
31        opts: ScheduleActivityOpts,
32    ) -> Result<WorkflowActivity> {
33        // Idempotency: short-circuit if (workflow_id, seq) already exists.
34        if let Some(existing) = self
35            .store
36            .get_activity_by_workflow_seq(workflow_id, seq)
37            .await?
38        {
39            return Ok(existing);
40        }
41
42        let now = timestamp_now();
43        let mut act = WorkflowActivity {
44            id: None,
45            workflow_id: workflow_id.to_string(),
46            seq,
47            name: name.to_string(),
48            task_queue: task_queue.to_string(),
49            input: input.map(String::from),
50            status: "PENDING".to_string(),
51            result: None,
52            error: None,
53            attempt: 1,
54            max_attempts: opts.max_attempts.unwrap_or(3),
55            initial_interval_secs: opts.initial_interval_secs.unwrap_or(1.0),
56            backoff_coefficient: opts.backoff_coefficient.unwrap_or(2.0),
57            start_to_close_secs: opts.start_to_close_secs.unwrap_or(300.0),
58            heartbeat_timeout_secs: opts.heartbeat_timeout_secs,
59            claimed_by: None,
60            scheduled_at: now,
61            started_at: None,
62            completed_at: None,
63            last_heartbeat: None,
64        };
65
66        let id = self.store.create_activity(&act).await?;
67        act.id = Some(id);
68
69        // Append ActivityScheduled event with the activity's seq
70        let event_seq = self.store.get_event_count(workflow_id).await? as i32 + 1;
71        self.store
72            .append_event(&WorkflowEvent {
73                id: None,
74                workflow_id: workflow_id.to_string(),
75                seq: event_seq,
76                event_type: "ActivityScheduled".to_string(),
77                payload: Some(
78                    serde_json::json!({
79                        "activity_id": id,
80                        "activity_seq": seq,
81                        "name": name,
82                        "task_queue": task_queue,
83                        "input": input,
84                    })
85                    .to_string(),
86                ),
87                timestamp: now,
88            })
89            .await?;
90
91        // Emit an ActivityInserted event carrying the activity row id,
92        // workflow id, queue, and name — task workers subscribe and
93        // dispatch matching activities (replacing the old PG trigger
94        // that did `pg_notify('assay_task_<queue>', NEW.id)`).
95        let ns = self
96            .store
97            .get_workflow(workflow_id)
98            .await?
99            .map(|w| w.namespace)
100            .unwrap_or_else(|| "main".to_string());
101        self.emit(
102            &ns,
103            WorkflowBusEvent::ActivityInserted {
104                activity_id: id,
105                workflow_id: workflow_id.to_string(),
106                task_queue: task_queue.to_string(),
107                name: name.to_string(),
108            },
109        )
110        .await;
111
112        // Transition workflow from PENDING to RUNNING on first scheduled activity
113        if let Some(wf) = self.store.get_workflow(workflow_id).await?
114            && wf.status == "PENDING"
115        {
116            self.store
117                .update_workflow_status(workflow_id, WorkflowStatus::Running, None, None)
118                .await?;
119        }
120
121        Ok(act)
122    }
123
124    pub async fn claim_activity(
125        &self,
126        task_queue: &str,
127        worker_id: &str,
128    ) -> Result<Option<WorkflowActivity>> {
129        self.store.claim_activity(task_queue, worker_id).await
130    }
131
132    pub async fn get_activity(&self, id: i64) -> Result<Option<WorkflowActivity>> {
133        self.store.get_activity(id).await
134    }
135
136    pub async fn retry_failed_activity(
137        &self,
138        workflow_id: &str,
139        requested_by: &str,
140        reason: &str,
141    ) -> Result<RetryFailedActivityResult> {
142        let result = self
143            .store
144            .retry_failed_activity(workflow_id, requested_by, reason, timestamp_now())
145            .await?;
146        if let RetryFailedActivityResult::Retried(retried) = &result {
147            let activity = &retried.activity;
148            let namespace = self
149                .store
150                .get_workflow(workflow_id)
151                .await?
152                .map(|workflow| workflow.namespace)
153                .unwrap_or_else(|| "main".to_string());
154            self.emit_retry_requested(
155                &namespace,
156                workflow_id,
157                activity.id.unwrap_or_default(),
158                activity.seq,
159            )
160            .await;
161        }
162        Ok(result)
163    }
164
165    /// Mark a successfully-executed activity complete and append an
166    /// `ActivityCompleted` event to the workflow event log so a replaying
167    /// workflow can pick up the cached result.
168    ///
169    /// `failed=true` is preserved for legacy callers that go straight
170    /// through complete with a non-retry path; new code should call
171    /// [`WorkflowCtx::fail_activity`] instead so retry policy is honored.
172    pub async fn complete_activity(
173        &self,
174        id: i64,
175        result: Option<&str>,
176        error: Option<&str>,
177        failed: bool,
178    ) -> Result<()> {
179        self.store
180            .complete_activity(id, result, error, failed)
181            .await?;
182
183        // Look up the activity so we can attribute the event correctly
184        let act = match self.store.get_activity(id).await? {
185            Some(a) => a,
186            None => return Ok(()),
187        };
188
189        let event_type = if failed {
190            "ActivityFailed"
191        } else {
192            "ActivityCompleted"
193        };
194        let payload = serde_json::json!({
195            "activity_id": id,
196            "activity_seq": act.seq,
197            "name": act.name,
198            "result": result.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()),
199            "error": error,
200        });
201        let event_seq = self.store.get_event_count(&act.workflow_id).await? as i32 + 1;
202        let workflow_id = act.workflow_id.clone();
203        self.store
204            .append_event(&WorkflowEvent {
205                id: None,
206                workflow_id: act.workflow_id,
207                seq: event_seq,
208                event_type: event_type.to_string(),
209                payload: Some(payload.to_string()),
210                timestamp: timestamp_now(),
211            })
212            .await?;
213        // wake the workflow task back up.
214        self.mark_and_emit_needs_dispatch(&workflow_id).await?;
215        Ok(())
216    }
217
218    /// Fail an activity, honoring its retry policy.
219    ///
220    /// If `attempt < max_attempts`, the activity is re-queued with
221    /// exponential backoff (`initial_interval_secs * backoff_coefficient^(attempt-1)`)
222    /// and `attempt` is incremented. **No event is appended** — retries
223    /// are an internal-engine concern, not workflow-visible.
224    ///
225    /// If `attempt >= max_attempts`, the activity is permanently FAILED
226    /// and an `ActivityFailed` event is appended so the workflow can react.
227    pub async fn fail_activity(&self, id: i64, error: &str) -> Result<()> {
228        let act = match self.store.get_activity(id).await? {
229            Some(a) => a,
230            None => return Ok(()),
231        };
232
233        if act.attempt < act.max_attempts {
234            // Compute exponential backoff: interval * coefficient^(attempt-1)
235            let backoff = act.initial_interval_secs * act.backoff_coefficient.powi(act.attempt - 1);
236            let next_scheduled_at = timestamp_now() + backoff;
237            self.store
238                .requeue_activity_for_retry(id, act.attempt + 1, next_scheduled_at)
239                .await?;
240            return Ok(());
241        }
242
243        // Out of retries — mark FAILED and surface to the workflow
244        self.store
245            .complete_activity(id, None, Some(error), true)
246            .await?;
247
248        let event_seq = self.store.get_event_count(&act.workflow_id).await? as i32 + 1;
249        let workflow_id = act.workflow_id.clone();
250        self.store
251            .append_event(&WorkflowEvent {
252                id: None,
253                workflow_id: act.workflow_id,
254                seq: event_seq,
255                event_type: "ActivityFailed".to_string(),
256                payload: Some(
257                    serde_json::json!({
258                        "activity_id": id,
259                        "activity_seq": act.seq,
260                        "name": act.name,
261                        "error": error,
262                        "final_attempt": act.attempt,
263                    })
264                    .to_string(),
265                ),
266                timestamp: timestamp_now(),
267            })
268            .await?;
269        // Wake the workflow task — handler needs to see the failure.
270        self.mark_and_emit_needs_dispatch(&workflow_id).await?;
271        Ok(())
272    }
273
274    pub async fn heartbeat_activity(&self, id: i64, details: Option<&str>) -> Result<()> {
275        self.store.heartbeat_activity(id, details).await
276    }
277
278    pub async fn record_side_effect(&self, workflow_id: &str, value: &str) -> Result<()> {
279        let now = timestamp_now();
280        let seq = self.store.get_event_count(workflow_id).await? as i32 + 1;
281        self.store
282            .append_event(&WorkflowEvent {
283                id: None,
284                workflow_id: workflow_id.to_string(),
285                seq,
286                event_type: "SideEffectRecorded".to_string(),
287                payload: Some(value.to_string()),
288                timestamp: now,
289            })
290            .await?;
291        Ok(())
292    }
293}