Skip to main content

assay_workflow/
activities.rs

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