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    /// The row write, the history event and the dispatch arming land in one
170    /// store transaction: a `COMPLETED` activity whose workflow replays it
171    /// as still pending is not a reachable state. Re-calling with the same
172    /// id is the repair path for a workflow task lost after the event
173    /// landed — it re-arms dispatch and never rewrites the stored result.
174    ///
175    /// `failed=true` is preserved for legacy callers that go straight
176    /// through complete with a non-retry path; new code should call
177    /// [`WorkflowCtx::fail_activity`] instead so retry policy is honored.
178    pub async fn complete_activity(
179        &self,
180        id: i64,
181        result: Option<&str>,
182        error: Option<&str>,
183        failed: bool,
184    ) -> Result<()> {
185        // Read before the write: the payload needs the activity's seq and
186        // name, and both are immutable once the row exists.
187        let act = match self.store.get_activity(id).await? {
188            Some(a) => a,
189            None => return Ok(()),
190        };
191
192        let event_type = if failed {
193            "ActivityFailed"
194        } else {
195            "ActivityCompleted"
196        };
197        let payload = settled_event_payload(id, act.seq, &act.name, result, error).to_string();
198        let outcome = self
199            .store
200            .settle_activity(&ActivitySettlement {
201                activity_id: id,
202                workflow_id: &act.workflow_id,
203                result,
204                error,
205                failed,
206                event_type,
207                payload: &payload,
208                now: timestamp_now(),
209            })
210            .await?;
211        if outcome == SettleOutcome::Repaired {
212            tracing::warn!(
213                activity_id = id,
214                workflow_id = %act.workflow_id,
215                "settled activity was missing its history event — repaired"
216            );
217        }
218        // wake the workflow task back up.
219        self.emit_needs_dispatch(&act.workflow_id).await;
220        Ok(())
221    }
222
223    /// Fail an activity, honoring its retry policy.
224    ///
225    /// If `attempt < max_attempts`, the activity is re-queued with
226    /// exponential backoff (`initial_interval_secs * backoff_coefficient^(attempt-1)`)
227    /// and `attempt` is incremented. **No event is appended** — retries
228    /// are an internal-engine concern, not workflow-visible.
229    ///
230    /// If `attempt >= max_attempts`, the activity is permanently FAILED
231    /// and an `ActivityFailed` event is appended so the workflow can react.
232    pub async fn fail_activity(&self, id: i64, error: &str) -> Result<()> {
233        let act = match self.store.get_activity(id).await? {
234            Some(a) => a,
235            None => return Ok(()),
236        };
237
238        if act.attempt < act.max_attempts {
239            // Compute exponential backoff: interval * coefficient^(attempt-1)
240            let backoff = act.initial_interval_secs * act.backoff_coefficient.powi(act.attempt - 1);
241            let next_scheduled_at = timestamp_now() + backoff;
242            self.store
243                .requeue_activity_for_retry(id, act.attempt + 1, next_scheduled_at)
244                .await?;
245            return Ok(());
246        }
247
248        // Out of retries — mark FAILED and surface to the workflow. Row,
249        // event and dispatch arming settle in one store transaction.
250        let payload = failed_event_payload(id, act.seq, &act.name, error, act.attempt).to_string();
251        self.store
252            .settle_activity(&ActivitySettlement {
253                activity_id: id,
254                workflow_id: &act.workflow_id,
255                result: None,
256                error: Some(error),
257                failed: true,
258                event_type: "ActivityFailed",
259                payload: &payload,
260                now: timestamp_now(),
261            })
262            .await?;
263        // Wake the workflow task — handler needs to see the failure.
264        self.emit_needs_dispatch(&act.workflow_id).await;
265        Ok(())
266    }
267
268    pub async fn heartbeat_activity(&self, id: i64, details: Option<&str>) -> Result<()> {
269        self.store.heartbeat_activity(id, details).await
270    }
271
272    pub async fn record_side_effect(&self, workflow_id: &str, value: &str) -> Result<()> {
273        let now = timestamp_now();
274        let seq = self.store.get_event_count(workflow_id).await? as i32 + 1;
275        self.store
276            .append_event(&WorkflowEvent {
277                id: None,
278                workflow_id: workflow_id.to_string(),
279                seq,
280                event_type: "SideEffectRecorded".to_string(),
281                payload: Some(value.to_string()),
282                timestamp: now,
283            })
284            .await?;
285        Ok(())
286    }
287}
288
289/// Payload of the `ActivityCompleted` / `ActivityFailed` event written when
290/// an activity settles. Shared with the reconciler so a repaired event is
291/// shaped exactly like one written inline.
292pub(crate) fn settled_event_payload(
293    activity_id: i64,
294    activity_seq: i32,
295    name: &str,
296    result: Option<&str>,
297    error: Option<&str>,
298) -> serde_json::Value {
299    serde_json::json!({
300        "activity_id": activity_id,
301        "activity_seq": activity_seq,
302        "name": name,
303        "result": result.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()),
304        "error": error,
305    })
306}
307
308/// Payload of the `ActivityFailed` event written when an activity exhausts
309/// its retry budget. Carries the attempt count the completion payload has
310/// no reason to.
311pub(crate) fn failed_event_payload(
312    activity_id: i64,
313    activity_seq: i32,
314    name: &str,
315    error: &str,
316    final_attempt: i32,
317) -> serde_json::Value {
318    serde_json::json!({
319        "activity_id": activity_id,
320        "activity_seq": activity_seq,
321        "name": name,
322        "error": error,
323        "final_attempt": final_attempt,
324    })
325}