Skip to main content

taquba_workflow/
runner.rs

1use std::collections::HashMap;
2use std::future::Future;
3use std::ops::{Deref, DerefMut};
4use std::sync::Arc;
5use std::time::Duration;
6
7use taquba::object_store::memory::InMemory;
8use taquba::{LeaseHandle, PermanentFailure, WorkerError};
9use tokio_util::sync::CancellationToken;
10
11use crate::effects::EffectsHandle;
12use crate::keys::RunId;
13use crate::kv::KvReadHandle;
14use crate::memo::{Memo, MemoStore};
15
16/// The delivery a handler runs under: the identity of the run and of
17/// the queue job delivering it, the attempt count and the delivery's
18/// handles. [`Step`] and [`jobs::JobContext`](crate::jobs::JobContext)
19/// dereference to it. It holds handles only; no queue is reachable
20/// through it.
21///
22/// Constructed by the runtime. A test constructs one with
23/// [`Delivery::detached`] and assigns the fields it needs.
24#[derive(Debug, Clone)]
25pub struct Delivery {
26    /// Caller-visible run identifier (the value passed to or generated by
27    /// [`crate::RunSpec`]).
28    pub run_id: RunId,
29    /// Submitter-supplied metadata, threaded through every step of the run.
30    /// Reserved `workflow.*` headers are stripped before the handler sees them.
31    pub headers: HashMap<String, String>,
32    /// The Taquba job ID of this delivery, useful for tracing.
33    pub job_id: String,
34    /// How many times Taquba has attempted this delivery. `1` on the
35    /// first attempt; `>1` after a lease expiry / nack retry.
36    pub attempts: u32,
37    /// The attempt limit: a transient failure on the attempt numbered
38    /// `max_attempts` ends the run.
39    pub max_attempts: u32,
40    /// Cooperative cancellation signal for the run. The runtime cancels
41    /// this token when [`crate::WorkflowRuntime::cancel`] is called while
42    /// this delivery is in flight, so a long-running handler (e.g. an LLM
43    /// call, a slow HTTP request) can short-circuit instead of running to
44    /// completion. Typical use:
45    ///
46    /// ```ignore
47    /// tokio::select! {
48    ///     out = do_slow_work(step) => out,
49    ///     _ = step.cancel_token.cancelled() => {
50    ///         Ok(StepOutcome::Cancel { reason: "cooperative".into() })
51    ///     }
52    /// }
53    /// ```
54    ///
55    /// Handlers that ignore the token remain correct: the runtime still
56    /// discards the outcome of a cancelled step and fires the terminal
57    /// hook with [`crate::TerminalStatus::Cancelled`]. Watching the token
58    /// only reduces cancellation latency for slow steps; it doesn't
59    /// change semantics.
60    ///
61    /// The token is a child of the claim's cancellation token. A
62    /// re-delivery of this step observes `is_cancelled() == true`
63    /// immediately, because the queue re-fires the claim's cancellation
64    /// token from the job's persisted cancellation. Cancelling this token
65    /// leaves the claim's token uncancelled, so the runtime does not treat
66    /// the step as externally cancelled.
67    pub cancel_token: CancellationToken,
68    /// The lease handle of this delivery. A long-running handler calls
69    /// [`LeaseHandle::ensure_at_least`] at progress points (or once,
70    /// with a slow call's timeout, before issuing it) so the step is
71    /// not re-queued while it still runs. A detached handle's calls
72    /// succeed without effect.
73    pub lease: LeaseHandle,
74    /// Per-step durable key-value store, scoped to this step's
75    /// `(run_id, step_number)`. Use to memoize expensive within-step
76    /// side effects (LLM calls, paid APIs) so an at-least-once retry
77    /// of this step doesn't re-pay for work the prior attempt already
78    /// did:
79    ///
80    /// ```ignore
81    /// let response = match step.memo.get("llm").await? {
82    ///     Some(cached) => deserialize(&cached),
83    ///     None => {
84    ///         let fresh = llm.complete(&prompt).await?;
85    ///         step.memo.put("llm", &serialize(&fresh)).await?;
86    ///         fresh
87    ///     }
88    /// };
89    /// ```
90    ///
91    /// See [`Memo`] for the full API.
92    pub memo: Memo,
93    /// Run-scoped durable key-value store, shared by every step of the
94    /// run. Entries live beside the per-step [`Delivery::memo`] entries
95    /// and are removed with them when the run's retention expires. Use
96    /// it for values a later step reads back, such as an accumulating
97    /// journal; the durable channel for the next step's input is
98    /// [`StepOutcome::Continue`]'s payload.
99    pub run_memo: Memo,
100    /// Application KV effects for this step. Writes and deletes staged
101    /// here are applied in the same transaction as the settlement that
102    /// commits the returned outcome, so application state cannot
103    /// diverge from the run's transition on a crash:
104    ///
105    /// ```ignore
106    /// step.effects.put(format!("app/runs/{}", step.run_id), b"done".to_vec())?;
107    /// Ok(StepOutcome::Succeed { result })
108    /// ```
109    ///
110    /// See [`EffectsHandle`] for the staging rules.
111    pub effects: EffectsHandle,
112    /// Read access to the caller KV namespace. A committed value (an earlier
113    /// step's applied effect, a [`crate::RunSpec::effects`] write, a direct
114    /// [`taquba::Queue::kv_put`]) is readable here. Effects staged by this step
115    /// become readable only after it settles. The intended use is a
116    /// read-then-stage marker check:
117    ///
118    /// ```ignore
119    /// if step.kv.get(b"app/indexed/doc-1").await?.is_none() {
120    ///     index_document(&step.payload).await?; // idempotent
121    ///     step.effects.put("app/indexed/doc-1", b"1".to_vec())?;
122    /// }
123    /// ```
124    ///
125    /// See [`KvReadHandle`] for the read semantics.
126    pub kv: KvReadHandle,
127}
128
129impl Delivery {
130    /// Whether this attempt is the last: a transient [`StepError`]
131    /// returned from it dead-letters the step and ends the run.
132    pub fn is_last_attempt(&self) -> bool {
133        self.attempts >= self.max_attempts
134    }
135
136    /// A delivery bound to no queue, for tests: run `detached`, attempt
137    /// 1 of 3, no headers, a new cancellation token, detached lease,
138    /// effects and KV handles, and memos over an in-memory object store.
139    pub fn detached() -> Self {
140        let run_id = RunId::new("detached").expect("a literal run id");
141        let memo_store = MemoStore::new(Arc::new(InMemory::new()), "memo");
142        Self {
143            run_id: run_id.clone(),
144            headers: HashMap::new(),
145            job_id: "detached".to_string(),
146            attempts: 1,
147            max_attempts: 3,
148            cancel_token: CancellationToken::new(),
149            lease: LeaseHandle::detached(),
150            memo: memo_store.new_memo(&run_id, 0),
151            run_memo: memo_store.new_run_memo(&run_id),
152            effects: EffectsHandle::detached(),
153            kv: KvReadHandle::detached(),
154        }
155    }
156}
157
158/// A single step within a workflow run, handed to [`StepRunner::run_step`]:
159/// the [`Delivery`] it runs under, which it dereferences to, plus the
160/// step number, the step's payload and the signal that reached it.
161///
162/// Mirrors [`taquba::JobRecord`]: the `payload` is opaque application bytes
163/// and `headers` carries user metadata you set at submission (reserved
164/// `workflow.*` keys are filtered out before the runner sees them).
165///
166/// Constructed by the runtime. A test constructs one with
167/// [`Step::detached`] and assigns the fields it needs.
168#[derive(Debug, Clone)]
169pub struct Step {
170    /// The delivery this step runs under.
171    pub delivery: Delivery,
172    /// Zero-based step number. Step 0 is always the first step of a run, with
173    /// the original submission input as its `payload`.
174    pub step_number: u32,
175    /// Application-defined bytes. For step 0 this is the submission `input`;
176    /// for later steps it is the bytes returned by the previous step's
177    /// [`StepOutcome::Continue`].
178    pub payload: Vec<u8>,
179    /// The signal payload, when this step was reached through a
180    /// [`Trigger::OnSignal`] wait that a signal resolved: the previous
181    /// step continued with `OnSignal`, and a
182    /// [`crate::WorkflowRuntime::signal`] call for the correlation key
183    /// arrived before the timeout. `None` when the timeout elapsed first
184    /// and on every step not preceded by an `OnSignal` wait.
185    pub signal: Option<Vec<u8>>,
186}
187
188impl Deref for Step {
189    type Target = Delivery;
190
191    fn deref(&self) -> &Delivery {
192        &self.delivery
193    }
194}
195
196impl DerefMut for Step {
197    fn deref_mut(&mut self) -> &mut Delivery {
198        &mut self.delivery
199    }
200}
201
202impl Step {
203    /// Step 0 of a [`Delivery::detached`] delivery with `payload` and no
204    /// signal, for tests.
205    ///
206    /// ```
207    /// use taquba_workflow::Step;
208    ///
209    /// let mut step = Step::detached(b"input");
210    /// step.step_number = 2;
211    /// step.attempts = 3;
212    /// assert_eq!(step.payload, b"input");
213    /// assert!(step.is_last_attempt());
214    /// ```
215    pub fn detached(payload: impl Into<Vec<u8>>) -> Self {
216        Self {
217            delivery: Delivery::detached(),
218            step_number: 0,
219            payload: payload.into(),
220            signal: None,
221        }
222    }
223}
224
225/// When the next step of a run becomes claimable. Set on the `when`
226/// field of [`StepOutcome::Continue`].
227#[derive(Debug, Clone)]
228pub enum Trigger {
229    /// The next step is claimable immediately.
230    Immediate,
231    /// The next step is claimable `Duration` from now.
232    After(Duration),
233    /// The next step is claimable when a signal for `correlation_key`
234    /// arrives via [`crate::WorkflowRuntime::signal`], or after `timeout`,
235    /// whichever comes first. The next step reads [`Step::signal`] to
236    /// distinguish the two: `Some(payload)` when a signal arrived, `None`
237    /// when the timeout elapsed first. A signal that arrived before this
238    /// step settled is consumed at settlement and the next step runs
239    /// immediately.
240    ///
241    /// One waiter per correlation key: registering a second waiter while
242    /// one is already waiting fails the step permanently. Choose keys
243    /// that are unique per waiter (e.g. include the run id).
244    OnSignal {
245        /// Caller-chosen key the signaller addresses.
246        correlation_key: String,
247        /// Upper bound on the wait; the next step runs with
248        /// [`Step::signal`] `None` when it elapses first.
249        timeout: Duration,
250    },
251}
252
253/// What the runner wants the runtime to do after this step.
254#[derive(Debug, Clone)]
255pub enum StepOutcome {
256    /// Run is not finished. Enqueue the next step with `payload` as its
257    /// bytes; `when` decides when it becomes claimable. The runtime
258    /// advances `step_number` by 1. The constructors
259    /// [`Self::continue_now`] and [`Self::continue_after`] build the
260    /// common forms.
261    Continue {
262        /// Bytes to hand to the next step's [`Step::payload`].
263        payload: Vec<u8>,
264        /// When the next step becomes claimable.
265        when: Trigger,
266    },
267    /// The run is finished successfully. The runtime acks the step and fires
268    /// the configured terminal hook with
269    /// [`crate::TerminalStatus::Succeeded`] and `result` as the body.
270    Succeed {
271        /// Final result bytes handed to the terminal hook.
272        result: Vec<u8>,
273    },
274    /// The run is finished as failed by the runner's verdict; the runner
275    /// ran to completion but the workflow's logical outcome is "no" (e.g.
276    /// a validation rule rejected the input, a policy check denied the
277    /// request, an agent decided the task can't be fulfilled). The runtime
278    /// acks the step and fires the terminal hook with
279    /// [`crate::TerminalStatus::Failed`] and `reason` as the error.
280    ///
281    /// Use this for *workflow-level* failures. For *infrastructure*
282    /// failures (network outage, downstream service down, etc.) return
283    /// `Err(StepError::transient)` or `Err(StepError::permanent)` instead;
284    /// those dead-letter the step so an operator can find it via
285    /// [`taquba::QueueView::dead_jobs`]. `Fail` is a successful execution with
286    /// a negative outcome and does not dead-letter.
287    Fail {
288        /// Human-readable reason recorded on [`crate::RunOutcome::error`].
289        reason: String,
290    },
291    /// The run is finished as cancelled by the runner. Use this when the
292    /// runner decides on its own that the workflow should stop early
293    /// without it being a logical failure (e.g. a downstream cancellation
294    /// signal arrived mid-step, the user-supplied input is now obsolete).
295    /// The runtime acks the step and fires the terminal hook with
296    /// [`crate::TerminalStatus::Cancelled`] and `reason` as the error.
297    ///
298    /// For *external* cancellation requested by another component in the
299    /// process, call [`crate::WorkflowRuntime::cancel`] instead; the
300    /// runtime translates that into the same `Cancelled` terminal state.
301    Cancel {
302        /// Human-readable reason recorded on [`crate::RunOutcome::error`].
303        reason: String,
304    },
305}
306
307impl StepOutcome {
308    /// Continue the run; the next step is claimable immediately.
309    pub fn continue_now(payload: Vec<u8>) -> Self {
310        Self::Continue {
311            payload,
312            when: Trigger::Immediate,
313        }
314    }
315
316    /// Continue the run; the next step is claimable `delay` from now.
317    pub fn continue_after(payload: Vec<u8>, delay: Duration) -> Self {
318        Self::Continue {
319            payload,
320            when: Trigger::After(delay),
321        }
322    }
323
324    /// Continue the run; the next step is claimable when a signal for
325    /// `correlation_key` arrives, or after `timeout` at the latest.
326    pub fn continue_on_signal(
327        payload: Vec<u8>,
328        correlation_key: impl Into<String>,
329        timeout: Duration,
330    ) -> Self {
331        Self::Continue {
332            payload,
333            when: Trigger::OnSignal {
334                correlation_key: correlation_key.into(),
335                timeout,
336            },
337        }
338    }
339}
340
341/// Failure outcomes the runner can return.
342#[derive(Debug, Clone)]
343pub struct StepError {
344    /// Human-readable message recorded on the underlying job's `last_error`.
345    pub message: String,
346    /// Whether to retry the step or fail the run immediately.
347    pub kind: StepErrorKind,
348}
349
350impl StepError {
351    /// Build a transient error: Taquba retries the step per the queue's
352    /// backoff/`max_attempts`. Once `max_attempts` is exhausted, the step is
353    /// dead-lettered and the run terminates as failed.
354    pub fn transient(message: impl Into<String>) -> Self {
355        Self {
356            message: message.into(),
357            kind: StepErrorKind::Transient,
358        }
359    }
360
361    /// Build a permanent error: the step is dead-lettered immediately and the
362    /// run terminates as failed.
363    pub fn permanent(message: impl Into<String>) -> Self {
364        Self {
365            message: message.into(),
366            kind: StepErrorKind::Permanent,
367        }
368    }
369
370    /// The worker error reporting this failure: a [`PermanentFailure`]
371    /// for a permanent one, a retrying error otherwise.
372    pub(crate) fn into_worker_error(self) -> WorkerError {
373        match self.kind {
374            StepErrorKind::Permanent => PermanentFailure::new(self.message).into(),
375            StepErrorKind::Transient => self.message.into(),
376        }
377    }
378}
379
380impl std::fmt::Display for StepError {
381    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382        f.write_str(&self.message)
383    }
384}
385
386impl std::error::Error for StepError {}
387
388impl From<crate::Error> for StepError {
389    fn from(err: crate::Error) -> Self {
390        let permanent = err.is_permanent();
391        let message = err.to_string();
392        if permanent {
393            Self::permanent(message)
394        } else {
395            Self::transient(message)
396        }
397    }
398}
399
400/// Whether a [`StepError`] should retry or fail the run.
401#[derive(Debug, Clone, Copy, PartialEq, Eq)]
402pub enum StepErrorKind {
403    /// Retry per the queue's backoff policy until `max_attempts` is reached.
404    Transient,
405    /// Dead-letter the step immediately; terminate the run as failed.
406    Permanent,
407}
408
409/// User-implemented logic that advances a single workflow step.
410///
411/// Implementations must be idempotent for the same `(run_id, step_number)`:
412/// Taquba is at-least-once, so a step can be claimed and processed more than
413/// once if a lease expires before the worker acks. Returning the same
414/// `StepOutcome` for the same input is the easiest way to satisfy this.
415pub trait StepRunner: Send + Sync {
416    /// Process a single step of a workflow run. Return [`StepOutcome::Continue`]
417    /// to enqueue the next step, [`StepOutcome::Succeed`] to finish the run
418    /// successfully, [`StepOutcome::Fail`] to terminate the run as Failed by
419    /// runner verdict, [`StepOutcome::Cancel`] to terminate the run as
420    /// Cancelled by runner verdict, or `Err(StepError)` to retry /
421    /// dead-letter on infrastructure errors.
422    fn run_step(
423        &self,
424        step: &Step,
425    ) -> impl Future<Output = std::result::Result<StepOutcome, StepError>> + Send;
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use crate::test_util::rid;
432
433    #[test]
434    fn from_workflow_error_maps_via_is_permanent() {
435        let permanent: StepError = crate::Error::InputMismatch(rid("run-1")).into();
436        assert_eq!(permanent.kind, StepErrorKind::Permanent);
437
438        let store_err = taquba::object_store::Error::NotFound {
439            path: "x".into(),
440            source: "missing".into(),
441        };
442        let transient: StepError = crate::Error::Store(store_err).into();
443        assert_eq!(transient.kind, StepErrorKind::Transient);
444    }
445}