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::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.complete_activity(id, result, error, failed).await?;
151
152        // Look up the activity so we can attribute the event correctly
153        let act = match self.store.get_activity(id).await? {
154            Some(a) => a,
155            None => return Ok(()),
156        };
157
158        let event_type = if failed {
159            "ActivityFailed"
160        } else {
161            "ActivityCompleted"
162        };
163        let payload = serde_json::json!({
164            "activity_id": id,
165            "activity_seq": act.seq,
166            "name": act.name,
167            "result": result.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()),
168            "error": error,
169        });
170        let event_seq = self.store.get_event_count(&act.workflow_id).await? as i32 + 1;
171        let workflow_id = act.workflow_id.clone();
172        self.store
173            .append_event(&WorkflowEvent {
174                id: None,
175                workflow_id: act.workflow_id,
176                seq: event_seq,
177                event_type: event_type.to_string(),
178                payload: Some(payload.to_string()),
179                timestamp: timestamp_now(),
180            })
181            .await?;
182        // Phase 9: the workflow has a new event the handler needs to see —
183        // wake the workflow task back up.
184        self.mark_and_emit_needs_dispatch(&workflow_id).await?;
185        Ok(())
186    }
187
188    /// Fail an activity, honoring its retry policy.
189    ///
190    /// If `attempt < max_attempts`, the activity is re-queued with
191    /// exponential backoff (`initial_interval_secs * backoff_coefficient^(attempt-1)`)
192    /// and `attempt` is incremented. **No event is appended** — retries
193    /// are an internal-engine concern, not workflow-visible.
194    ///
195    /// If `attempt >= max_attempts`, the activity is permanently FAILED
196    /// and an `ActivityFailed` event is appended so the workflow can react.
197    pub async fn fail_activity(&self, id: i64, error: &str) -> Result<()> {
198        let act = match self.store.get_activity(id).await? {
199            Some(a) => a,
200            None => return Ok(()),
201        };
202
203        if act.attempt < act.max_attempts {
204            // Compute exponential backoff: interval * coefficient^(attempt-1)
205            let backoff = act.initial_interval_secs
206                * 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(
250        &self,
251        workflow_id: &str,
252        value: &str,
253    ) -> Result<()> {
254        let now = timestamp_now();
255        let seq = self.store.get_event_count(workflow_id).await? as i32 + 1;
256        self.store
257            .append_event(&WorkflowEvent {
258                id: None,
259                workflow_id: workflow_id.to_string(),
260                seq,
261                event_type: "SideEffectRecorded".to_string(),
262                payload: Some(value.to_string()),
263                timestamp: now,
264            })
265            .await?;
266        Ok(())
267    }
268}