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    /// Mark a successfully-executed activity complete and append an
137    /// `ActivityCompleted` event to the workflow event log so a replaying
138    /// workflow can pick up the cached result.
139    ///
140    /// `failed=true` is preserved for legacy callers that go straight
141    /// through complete with a non-retry path; new code should call
142    /// [`WorkflowCtx::fail_activity`] instead so retry policy is honored.
143    pub async fn complete_activity(
144        &self,
145        id: i64,
146        result: Option<&str>,
147        error: Option<&str>,
148        failed: bool,
149    ) -> Result<()> {
150        self.store
151            .complete_activity(id, result, error, failed)
152            .await?;
153
154        // Look up the activity so we can attribute the event correctly
155        let act = match self.store.get_activity(id).await? {
156            Some(a) => a,
157            None => return Ok(()),
158        };
159
160        let event_type = if failed {
161            "ActivityFailed"
162        } else {
163            "ActivityCompleted"
164        };
165        let payload = serde_json::json!({
166            "activity_id": id,
167            "activity_seq": act.seq,
168            "name": act.name,
169            "result": result.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()),
170            "error": error,
171        });
172        let event_seq = self.store.get_event_count(&act.workflow_id).await? as i32 + 1;
173        let workflow_id = act.workflow_id.clone();
174        self.store
175            .append_event(&WorkflowEvent {
176                id: None,
177                workflow_id: act.workflow_id,
178                seq: event_seq,
179                event_type: event_type.to_string(),
180                payload: Some(payload.to_string()),
181                timestamp: timestamp_now(),
182            })
183            .await?;
184        // wake the workflow task back up.
185        self.mark_and_emit_needs_dispatch(&workflow_id).await?;
186        Ok(())
187    }
188
189    /// Fail an activity, honoring its retry policy.
190    ///
191    /// If `attempt < max_attempts`, the activity is re-queued with
192    /// exponential backoff (`initial_interval_secs * backoff_coefficient^(attempt-1)`)
193    /// and `attempt` is incremented. **No event is appended** — retries
194    /// are an internal-engine concern, not workflow-visible.
195    ///
196    /// If `attempt >= max_attempts`, the activity is permanently FAILED
197    /// and an `ActivityFailed` event is appended so the workflow can react.
198    pub async fn fail_activity(&self, id: i64, error: &str) -> Result<()> {
199        let act = match self.store.get_activity(id).await? {
200            Some(a) => a,
201            None => return Ok(()),
202        };
203
204        if act.attempt < act.max_attempts {
205            // Compute exponential backoff: interval * coefficient^(attempt-1)
206            let backoff = act.initial_interval_secs * act.backoff_coefficient.powi(act.attempt - 1);
207            let next_scheduled_at = timestamp_now() + backoff;
208            self.store
209                .requeue_activity_for_retry(id, act.attempt + 1, next_scheduled_at)
210                .await?;
211            return Ok(());
212        }
213
214        // Out of retries — mark FAILED and surface to the workflow
215        self.store
216            .complete_activity(id, None, Some(error), true)
217            .await?;
218
219        let event_seq = self.store.get_event_count(&act.workflow_id).await? as i32 + 1;
220        let workflow_id = act.workflow_id.clone();
221        self.store
222            .append_event(&WorkflowEvent {
223                id: None,
224                workflow_id: act.workflow_id,
225                seq: event_seq,
226                event_type: "ActivityFailed".to_string(),
227                payload: Some(
228                    serde_json::json!({
229                        "activity_id": id,
230                        "activity_seq": act.seq,
231                        "name": act.name,
232                        "error": error,
233                        "final_attempt": act.attempt,
234                    })
235                    .to_string(),
236                ),
237                timestamp: timestamp_now(),
238            })
239            .await?;
240        // Wake the workflow task — handler needs to see the failure.
241        self.mark_and_emit_needs_dispatch(&workflow_id).await?;
242        Ok(())
243    }
244
245    pub async fn heartbeat_activity(&self, id: i64, details: Option<&str>) -> Result<()> {
246        self.store.heartbeat_activity(id, details).await
247    }
248
249    pub async fn record_side_effect(&self, workflow_id: &str, value: &str) -> Result<()> {
250        let now = timestamp_now();
251        let seq = self.store.get_event_count(workflow_id).await? as i32 + 1;
252        self.store
253            .append_event(&WorkflowEvent {
254                id: None,
255                workflow_id: workflow_id.to_string(),
256                seq,
257                event_type: "SideEffectRecorded".to_string(),
258                payload: Some(value.to_string()),
259                timestamp: now,
260            })
261            .await?;
262        Ok(())
263    }
264}