Skip to main content

backbone_core/workflow/
spec.rs

1//! What the schema generator writes for a workflow: its steps as data, and the one trait that
2//! performs what only typed code can.
3//!
4//! A [`WorkflowSpec`] is the compiled form of a `schema/workflows/<name>.workflow.yaml` with
5//! `config.executor: engine`. The generated module carries it as JSON (`WorkflowSpec::from_json`)
6//! beside a [`WorkflowDefinition`] implementation, which does the three things a spec cannot say:
7//! call an entity's generated service (create, update, delete, query, fetch), move a record
8//! through its state machine, and call the hand-written verbs and functions of the workflow's
9//! `Actions` trait.
10
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15use super::expr::{Dur, Expr, Tpl};
16use crate::service::ServiceResult;
17use crate::write_hooks::WriteCtx;
18
19/// How long to wait between attempts of a step that failed for a transient reason.
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum Backoff {
23    /// Doubling from the engine's base up to its cap.
24    Exponential,
25    /// The same wait every time, in seconds.
26    Fixed(i64),
27}
28
29/// The retry policy of a workflow or of one step.
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct RetrySpec {
32    /// Attempts in all, the first included.
33    pub max_attempts: u32,
34    /// The wait between them.
35    pub backoff: Backoff,
36}
37
38/// What a step does when it succeeds (or when a wait is answered or times out).
39#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
40pub struct Outcome {
41    /// Context keys to set, evaluated in order.
42    #[serde(default)]
43    pub set: Vec<(String, Tpl)>,
44    /// The step to go to. `None` ends the run of this step without a successor, which is an
45    /// error for anything but a terminal.
46    #[serde(default)]
47    pub next: Option<String>,
48}
49
50/// What a step does when it fails.
51#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
52pub struct FailureSpec {
53    /// The step to go to.
54    #[serde(default)]
55    pub next: Option<String>,
56    /// Context keys to set when the failure is routed to `next`.
57    #[serde(default)]
58    pub set: Vec<(String, Tpl)>,
59    /// Attempts for a transient failure; the workflow's, else the engine's, when absent.
60    #[serde(default)]
61    pub retry: Option<RetrySpec>,
62    /// What to do once the attempts are used up, when it differs from `next`.
63    #[serde(default)]
64    pub on_exhausted: Option<Outcome>,
65}
66
67/// One branch of a condition step; the first whose `when` holds is taken, a branch with no `when`
68/// is the `else`.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70pub struct Branch {
71    /// The condition.
72    #[serde(default)]
73    pub when: Option<Expr>,
74    /// Context keys to set when it is taken.
75    #[serde(default)]
76    pub set: Vec<(String, Tpl)>,
77    /// Where it goes.
78    pub next: String,
79}
80
81/// One equality of a wait's condition: the event's field must equal the context expression.
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct WaitKey {
84    /// A top-level field of the event's payload.
85    pub event_field: String,
86    /// What it must equal, read from the context when the wait is entered.
87    pub context: Expr,
88}
89
90/// How a terminal step ends the instance.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum Terminal {
94    /// `completed`.
95    Completed,
96    /// `failed`.
97    Failed,
98    /// `expired`.
99    Expired,
100    /// `cancelled`.
101    Cancelled,
102}
103
104/// An event a terminal step stages.
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106pub struct EmitSpec {
107    /// The event type.
108    pub event: String,
109    /// The payload.
110    pub data: Tpl,
111}
112
113/// What a step is.
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
115#[serde(tag = "kind", rename_all = "snake_case")]
116pub enum StepKind {
117    /// An action: an engine action (`audit_log`, `emit_event`, `send_email`, `notify`,
118    /// `send_notification`, `send_sms`), or one the [`WorkflowDefinition`] performs.
119    Action {
120        /// The verb.
121        action: String,
122        /// The entity of an entity action.
123        #[serde(default)]
124        entity: Option<String>,
125        /// The record an update, delete or fetch is about.
126        #[serde(default)]
127        id: Option<Tpl>,
128        /// The parameters, rendered when the step runs.
129        #[serde(default)]
130        params: Vec<(String, Tpl)>,
131        /// What a success does.
132        on_success: Outcome,
133        /// What a hand call that outlasts the step's `call_timeout` does, instead of failing.
134        #[serde(default)]
135        on_timeout: Option<Outcome>,
136    },
137    /// A state-machine verb on a record.
138    Transition {
139        /// The entity.
140        entity: String,
141        /// The record.
142        id: Tpl,
143        /// The verb.
144        verb: String,
145        /// Extra fields written with the move.
146        #[serde(default)]
147        params: Vec<(String, Tpl)>,
148        /// What a success does.
149        on_success: Outcome,
150    },
151    /// A branch on conditions, in order.
152    Condition {
153        /// The branches.
154        branches: Vec<Branch>,
155    },
156    /// A durable wait.
157    Wait {
158        /// The event waited for; `None` is a pure timer.
159        #[serde(default)]
160        event: Option<String>,
161        /// The equalities that match an event to this instance.
162        #[serde(default)]
163        key: Vec<WaitKey>,
164        /// How long to wait.
165        #[serde(default)]
166        timeout: Option<Dur>,
167        /// What an event does.
168        #[serde(default)]
169        on_event: Option<Outcome>,
170        /// What the timeout does.
171        #[serde(default)]
172        on_timeout: Option<Outcome>,
173    },
174    /// An end.
175    Terminal {
176        /// How it ends.
177        status: Terminal,
178        /// Why, for a person reading the log.
179        #[serde(default)]
180        reason: Option<Tpl>,
181        /// An event to stage.
182        #[serde(default)]
183        emit: Option<EmitSpec>,
184        /// Run compensation after ending as failed.
185        #[serde(default)]
186        compensate: bool,
187        /// A value stored as `context.result` when the instance ends.
188        #[serde(default)]
189        result: Option<Tpl>,
190    },
191}
192
193/// One step.
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195pub struct StepSpec {
196    /// Its name, unique in the workflow.
197    pub name: String,
198    /// A guard: when it does not hold the step is skipped and the run goes to `on_success.next`.
199    #[serde(default)]
200    pub guard: Option<Expr>,
201    /// What it is.
202    #[serde(flatten)]
203    pub kind: StepKind,
204    /// What a failure does.
205    #[serde(default)]
206    pub on_failure: Option<FailureSpec>,
207    /// Seconds a hand call may take (not the wait step's own `timeout`, which is part of its kind).
208    #[serde(default)]
209    pub call_timeout: Option<i64>,
210}
211
212/// One compensation step.
213#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
214pub struct CompStep {
215    /// Its name; the step log records it as `compensate:<name>`.
216    pub name: String,
217    /// It runs only when this holds (`steps.<name>.completed == true`).
218    #[serde(default)]
219    pub when: Option<Expr>,
220    /// The verb.
221    pub action: String,
222    /// The entity of an entity action.
223    #[serde(default)]
224    pub entity: Option<String>,
225    /// The record.
226    #[serde(default)]
227    pub id: Option<Tpl>,
228    /// The parameters.
229    #[serde(default)]
230    pub params: Vec<(String, Tpl)>,
231    /// The state-machine verb, when the action is `transition`.
232    #[serde(default)]
233    pub verb: Option<String>,
234}
235
236/// The compiled workflow.
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238pub struct WorkflowSpec {
239    /// Its name (`PasswordReset`).
240    pub name: String,
241    /// Its version, from the file.
242    pub version: i32,
243    /// The event that starts an instance.
244    pub trigger_event: String,
245    /// Context keys filled from the start event: `company_id: event.company_id`.
246    #[serde(default)]
247    pub extract: Vec<(String, Expr)>,
248    /// The context an instance starts with, before `extract`.
249    #[serde(default)]
250    pub context: Vec<(String, Value)>,
251    /// `false` runs the whole flow in one transaction and allows no wait.
252    pub persistence: bool,
253    /// How long an instance may live; passing it while waiting ends it `expired`.
254    #[serde(default)]
255    pub timeout: Option<Dur>,
256    /// The workflow-wide retry policy.
257    #[serde(default)]
258    pub retry: Option<RetrySpec>,
259    /// The first step is the first of these.
260    pub steps: Vec<StepSpec>,
261    /// Compensation, in declaration order; it runs in reverse.
262    #[serde(default)]
263    pub compensation: Vec<CompStep>,
264}
265
266impl WorkflowSpec {
267    /// Read the JSON a generated module carries.
268    ///
269    /// # Panics
270    /// When the text is not a spec: the generator wrote it, so that is a bug of the generator or a
271    /// version mismatch between it and this crate, and it is loudest at startup.
272    pub fn from_json(text: &str) -> Self {
273        match serde_json::from_str(text) {
274            Ok(spec) => spec,
275            Err(e) => panic!("a generated workflow spec does not parse (generator and backbone-core disagree): {e}"),
276        }
277    }
278
279    /// The step called `name`.
280    pub fn step(&self, name: &str) -> Option<&StepSpec> {
281        self.steps.iter().find(|s| s.name == name)
282    }
283
284    /// The events this workflow reacts to: its trigger and every event a wait names.
285    pub fn events(&self) -> Vec<&str> {
286        let mut out = vec![self.trigger_event.as_str()];
287        for step in &self.steps {
288            if let StepKind::Wait { event: Some(e), .. } = &step.kind {
289                if !out.contains(&e.as_str()) {
290                    out.push(e.as_str());
291                }
292            }
293        }
294        out
295    }
296}
297
298/// What a [`WorkflowDefinition`] is asked to perform.
299#[derive(Debug)]
300pub struct Call<'a> {
301    /// The step (or `compensate:<name>`) asking. A generated definition dispatches on it.
302    pub step: &'a str,
303    /// The verb.
304    pub action: &'a str,
305    /// The entity of an entity action or a transition.
306    pub entity: Option<&'a str>,
307    /// The record, rendered from the step's `id`, or from `params.id`.
308    pub id: Option<String>,
309    /// The state-machine verb of a transition step.
310    pub verb: Option<&'a str>,
311    /// The parameters, rendered.
312    pub params: &'a Value,
313    /// The instance's context (the `wf` of a hand verb).
314    pub context: &'a Value,
315}
316
317/// A workflow the engine can run: its spec, and what only typed code performs.
318#[async_trait]
319pub trait WorkflowDefinition: Send + Sync {
320    /// The compiled workflow.
321    fn spec(&self) -> &WorkflowSpec;
322
323    /// Perform an action the engine does not: an entity action through the entity's generated
324    /// service, a transition, or a hand-written verb. The answer is the step's `result`. An error
325    /// of a kind a caller can fix (`NotFound`, `Validation`, `Violations`, `AlreadyExists`) is a
326    /// business failure and is not retried; any other is transient and is.
327    async fn perform(&self, ctx: &WriteCtx, call: &Call<'_>) -> ServiceResult<Value>;
328
329    /// Call a function the workflow declares under `functions:`.
330    async fn function(&self, ctx: &WriteCtx, name: &str, args: &[Value]) -> ServiceResult<Value> {
331        let _ = (ctx, args);
332        Err(crate::service::ServiceError::Internal(format!(
333            "function `{name}` is not implemented"
334        )))
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use serde_json::json;
342
343    /// A wait's own `timeout` and a step's `call_timeout` live side by side in one JSON object: the
344    /// kind is flattened into the step, so the two must not share a name.
345    #[test]
346    fn a_wait_keeps_its_timeout_apart_from_the_steps_call_timeout() {
347        let spec = WorkflowSpec::from_json(
348            &json!({
349                "name": "W", "version": 1, "trigger_event": "E", "persistence": true,
350                "steps": [
351                    { "name": "w", "kind": "wait", "event": "X", "key": [], "timeout": { "secs": 900 },
352                      "on_event": { "next": "d" }, "on_timeout": { "next": "d" }, "call_timeout": 30 },
353                    { "name": "d", "kind": "terminal", "status": "completed" }
354                ]
355            })
356            .to_string(),
357        );
358        let wait = spec.step("w").unwrap();
359        assert_eq!(wait.call_timeout, Some(30));
360        assert!(matches!(
361            &wait.kind,
362            StepKind::Wait {
363                timeout: Some(Dur::Secs(900)),
364                ..
365            }
366        ));
367        assert_eq!(spec.events(), vec!["E", "X"]);
368    }
369}