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}