Skip to main content

runifold_workflow/
checkpoint.rs

1use std::{collections::BTreeMap, fmt, num::NonZeroU16, sync::Arc};
2
3use runifold_core::{
4    Checkpoint, CheckpointError, CheckpointErrorKind, CheckpointId, CheckpointStore, RunContext,
5    RunId, Usage,
6};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::{StepId, WorkflowError, WorkflowOutcome, WorkflowWait};
11use crate::{WorkflowLease, WorkflowStore};
12
13const CHECKPOINT_KIND: &str = "runifold.workflow";
14const CHECKPOINT_SCHEMA_VERSION: u32 = 4;
15const MIN_CHECKPOINT_SCHEMA_VERSION: u32 = 3;
16
17/// Recovery behavior for a workflow interrupted inside one node.
18#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
19#[non_exhaustive]
20pub enum WorkflowResumePolicy {
21    /// Reject recovery that could duplicate model cost or external effects.
22    #[default]
23    RejectAmbiguous,
24    /// Explicitly retry only the interrupted workflow node.
25    RetryInterruptedStep,
26}
27
28/// Maximum number of immutable checkpoint revisions returned by one query.
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30pub struct WorkflowCheckpointHistoryLimit(NonZeroU16);
31
32impl WorkflowCheckpointHistoryLimit {
33    /// Creates a bounded history page limit.
34    ///
35    /// # Errors
36    ///
37    /// Rejects zero and values above 256.
38    pub fn new(value: u16) -> Result<Self, CheckpointError> {
39        NonZeroU16::new(value)
40            .filter(|value| value.get() <= 256)
41            .map(Self)
42            .ok_or_else(|| {
43                CheckpointError::new(
44                    CheckpointErrorKind::InvalidPayload,
45                    "workflow checkpoint history limit must be in 1..=256",
46                )
47            })
48    }
49
50    /// Returns the validated page size.
51    pub const fn get(self) -> u16 {
52        self.0.get()
53    }
54}
55
56/// Immutable, typed view of one historical workflow checkpoint revision.
57#[derive(Clone, Debug, PartialEq)]
58pub struct WorkflowCheckpointRevision {
59    /// Workflow checkpoint whose history owns this revision.
60    pub checkpoint_id: CheckpointId,
61    /// Monotonic revision within the checkpoint.
62    pub revision: u64,
63    /// Run that produced the immutable revision.
64    pub run_id: RunId,
65    /// Store timestamp carried by the checkpoint envelope.
66    pub updated_at_ms: u64,
67    /// Validated workflow state at this revision.
68    pub state: WorkflowCheckpointState,
69}
70
71impl WorkflowCheckpointRevision {
72    #[doc(hidden)]
73    pub fn from_checkpoint(checkpoint: Checkpoint) -> Result<Self, CheckpointError> {
74        decode_revision(checkpoint)
75    }
76}
77
78/// Explicit safety policy for forking a historical checkpoint.
79#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
80#[serde(rename_all = "snake_case")]
81#[non_exhaustive]
82pub enum WorkflowForkPolicy {
83    /// Fork only from a stable boundary that cannot repeat an ambiguous node.
84    #[default]
85    RejectAmbiguous,
86    /// Re-run one serial node whose checkpoint was persisted as in-flight.
87    RetryInterruptedStep,
88}
89
90/// Idempotent command that creates a new execution from immutable history.
91#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
92pub struct WorkflowForkCommand {
93    /// Caller-stable identity of the new execution branch.
94    pub fork_checkpoint_id: CheckpointId,
95    /// Existing workflow whose history is being selected.
96    pub source_checkpoint_id: CheckpointId,
97    /// Exact immutable source revision.
98    pub source_revision: u64,
99    /// Explicit ambiguous-replay policy.
100    pub policy: WorkflowForkPolicy,
101}
102
103impl WorkflowForkCommand {
104    /// Creates a fork command with a generated target identity.
105    pub fn new(
106        source_checkpoint_id: CheckpointId,
107        source_revision: u64,
108        policy: WorkflowForkPolicy,
109    ) -> Self {
110        Self::with_id(
111            CheckpointId::new(),
112            source_checkpoint_id,
113            source_revision,
114            policy,
115        )
116    }
117
118    /// Creates a retryable fork command with a caller-owned target identity.
119    pub const fn with_id(
120        fork_checkpoint_id: CheckpointId,
121        source_checkpoint_id: CheckpointId,
122        source_revision: u64,
123        policy: WorkflowForkPolicy,
124    ) -> Self {
125        Self {
126            fork_checkpoint_id,
127            source_checkpoint_id,
128            source_revision,
129            policy,
130        }
131    }
132
133    #[doc(hidden)]
134    pub fn prepare_checkpoint(&self, source: Checkpoint) -> Result<Checkpoint, CheckpointError> {
135        fork_checkpoint(source, self.fork_checkpoint_id, self.policy)
136    }
137}
138
139/// Immutable parent relationship of one forked workflow execution.
140#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
141pub struct WorkflowLineage {
142    /// Source workflow checkpoint.
143    pub parent_checkpoint_id: CheckpointId,
144    /// Exact parent revision selected for the fork.
145    pub parent_revision: u64,
146    /// Safety policy used to create the child.
147    pub policy: WorkflowForkPolicy,
148}
149
150/// Result of an idempotent workflow fork command.
151#[derive(Clone, Copy, Debug, Eq, PartialEq)]
152#[non_exhaustive]
153pub enum WorkflowForkOutcome {
154    /// A new workflow branch was atomically created.
155    Created {
156        /// New branch identity.
157        checkpoint_id: CheckpointId,
158    },
159    /// The same target identity was already bound to the same source.
160    Duplicate {
161        /// Existing branch identity.
162        checkpoint_id: CheckpointId,
163    },
164}
165
166/// Persisted workflow execution phase.
167#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
168#[serde(tag = "state", rename_all = "snake_case")]
169#[non_exhaustive]
170pub enum WorkflowCheckpointPhase {
171    /// Stable output is ready for the next node.
172    Ready,
173    /// One node may have partially executed.
174    StepInFlight {
175        /// Stable interrupted node identity.
176        step: StepId,
177    },
178    /// The worker lease was released while this node awaits a durable wake.
179    Waiting {
180        /// Stable waiting node identity.
181        step: StepId,
182        /// Durable wake condition.
183        wait: WorkflowWait,
184    },
185    /// A parallel node has one or more incomplete branches.
186    ParallelInFlight {
187        /// Stable parallel node identity.
188        step: StepId,
189        /// Durable branch progress keyed independently of completion order.
190        branches: BTreeMap<StepId, ParallelBranchCheckpoint>,
191    },
192    /// A first-success race has no durable winner yet, or awaits commit.
193    RaceInFlight {
194        /// Stable race node identity.
195        step: StepId,
196        /// Durable branch progress keyed independently of completion order.
197        branches: BTreeMap<StepId, ParallelBranchCheckpoint>,
198    },
199    /// The workflow reached a terminal output.
200    Completed {
201        /// Complete canonical workflow result.
202        outcome: WorkflowOutcome,
203    },
204}
205
206/// Persisted state of one branch inside an in-flight parallel node.
207#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
208#[serde(tag = "state", rename_all = "snake_case")]
209#[non_exhaustive]
210pub enum ParallelBranchCheckpoint {
211    /// The branch may have partially executed.
212    InFlight,
213    /// The branch returned a stable canonical output.
214    Completed {
215        /// Canonical branch output.
216        output: Value,
217    },
218    /// The branch returned a known failure.
219    Failed {
220        /// Safe persisted failure explanation.
221        message: String,
222    },
223    /// The branch was abandoned after another branch won.
224    Cancelled,
225}
226
227/// Versioned workflow state stored in a domain-neutral checkpoint envelope.
228#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
229pub struct WorkflowCheckpointState {
230    /// Stable workflow definition name.
231    pub workflow: String,
232    /// Caller-managed definition version.
233    pub workflow_version: u32,
234    /// Ordered node layout used to reject incompatible definitions.
235    pub layout: Vec<StepId>,
236    /// Index of the next node to execute.
237    pub next_index: usize,
238    /// Canonical value presented to the next node.
239    pub value: Value,
240    /// Stable outputs of all completed nodes.
241    pub outputs: BTreeMap<StepId, Value>,
242    /// Shared usage snapshot at persistence time.
243    pub usage: Usage,
244    /// Current recovery phase.
245    pub phase: WorkflowCheckpointPhase,
246}
247
248impl WorkflowCheckpointState {
249    pub(crate) fn outcome(&self) -> Option<WorkflowOutcome> {
250        match &self.phase {
251            WorkflowCheckpointPhase::Completed { outcome } => Some(outcome.clone()),
252            _ => None,
253        }
254    }
255}
256
257/// Stable handle binding one workflow checkpoint identity to a store.
258#[derive(Clone)]
259pub struct WorkflowCheckpoint {
260    id: CheckpointId,
261    backend: WorkflowCheckpointBackend,
262}
263
264#[derive(Clone)]
265enum WorkflowCheckpointBackend {
266    Local(Arc<dyn CheckpointStore>),
267    Distributed {
268        store: Arc<dyn WorkflowStore>,
269        lease: WorkflowLease,
270    },
271}
272
273impl WorkflowCheckpoint {
274    /// Creates a new workflow checkpoint handle.
275    pub fn new(store: Arc<dyn CheckpointStore>) -> Self {
276        Self {
277            id: CheckpointId::new(),
278            backend: WorkflowCheckpointBackend::Local(store),
279        }
280    }
281
282    /// Reconnects to an existing workflow checkpoint.
283    pub fn existing(id: CheckpointId, store: Arc<dyn CheckpointStore>) -> Self {
284        Self {
285            id,
286            backend: WorkflowCheckpointBackend::Local(store),
287        }
288    }
289
290    /// Binds a distributed checkpoint to the current fenced workflow lease.
291    pub fn distributed(store: Arc<dyn WorkflowStore>, lease: WorkflowLease) -> Self {
292        Self {
293            id: lease.checkpoint_id,
294            backend: WorkflowCheckpointBackend::Distributed { store, lease },
295        }
296    }
297
298    /// Returns the stable checkpoint identity.
299    pub const fn id(&self) -> CheckpointId {
300        self.id
301    }
302
303    /// Loads and validates the latest typed workflow state.
304    ///
305    /// # Errors
306    ///
307    /// Returns [`CheckpointError`] for storage or payload failures.
308    pub fn load(&self) -> Result<(Checkpoint, WorkflowCheckpointState), CheckpointError> {
309        let WorkflowCheckpointBackend::Local(store) = &self.backend else {
310            return Err(CheckpointError::new(
311                CheckpointErrorKind::Storage,
312                "distributed workflow checkpoints must be loaded asynchronously",
313            ));
314        };
315        let checkpoint = store.load(self.id)?;
316        decode(checkpoint)
317    }
318
319    /// Loads and validates either a local or distributed checkpoint.
320    ///
321    /// # Errors
322    ///
323    /// Returns [`CheckpointError`] for ownership, storage, or payload failures.
324    pub async fn load_async(
325        &self,
326    ) -> Result<(Checkpoint, WorkflowCheckpointState), CheckpointError> {
327        let checkpoint = match &self.backend {
328            WorkflowCheckpointBackend::Local(store) => store.load(self.id)?,
329            WorkflowCheckpointBackend::Distributed { store, lease } => {
330                store.load_checkpoint(lease.clone()).await?
331            }
332        };
333        decode(checkpoint)
334    }
335
336    async fn compare_and_swap(
337        &self,
338        checkpoint: &Checkpoint,
339        expected_revision: Option<u64>,
340    ) -> Result<(), CheckpointError> {
341        match &self.backend {
342            WorkflowCheckpointBackend::Local(store) => {
343                store.compare_and_swap(checkpoint, expected_revision)
344            }
345            WorkflowCheckpointBackend::Distributed { store, lease } => {
346                store
347                    .compare_and_swap_checkpoint(
348                        lease.clone(),
349                        checkpoint.clone(),
350                        expected_revision,
351                    )
352                    .await
353            }
354        }
355    }
356}
357
358fn decode(
359    checkpoint: Checkpoint,
360) -> Result<(Checkpoint, WorkflowCheckpointState), CheckpointError> {
361    if checkpoint.kind != CHECKPOINT_KIND
362        || !(MIN_CHECKPOINT_SCHEMA_VERSION..=CHECKPOINT_SCHEMA_VERSION)
363            .contains(&checkpoint.schema_version)
364    {
365        return Err(CheckpointError::new(
366            CheckpointErrorKind::InvalidPayload,
367            "checkpoint kind or schema version is not supported",
368        ));
369    }
370    let state = serde_json::from_value(checkpoint.payload.clone()).map_err(|error| {
371        CheckpointError::new(CheckpointErrorKind::InvalidPayload, error.to_string())
372    })?;
373    Ok((checkpoint, state))
374}
375
376pub(crate) fn decode_revision(
377    checkpoint: Checkpoint,
378) -> Result<WorkflowCheckpointRevision, CheckpointError> {
379    let (checkpoint, state) = decode(checkpoint)?;
380    Ok(WorkflowCheckpointRevision {
381        checkpoint_id: checkpoint.id,
382        revision: checkpoint.revision,
383        run_id: checkpoint.run_id,
384        updated_at_ms: checkpoint.updated_at_ms,
385        state,
386    })
387}
388
389pub(crate) fn fork_checkpoint(
390    source: Checkpoint,
391    target: CheckpointId,
392    policy: WorkflowForkPolicy,
393) -> Result<Checkpoint, CheckpointError> {
394    let (_, mut state) = decode(source)?;
395    match &state.phase {
396        WorkflowCheckpointPhase::StepInFlight { .. }
397            if policy == WorkflowForkPolicy::RetryInterruptedStep =>
398        {
399            state.phase = WorkflowCheckpointPhase::Ready;
400        }
401        WorkflowCheckpointPhase::StepInFlight { .. }
402        | WorkflowCheckpointPhase::ParallelInFlight { .. }
403        | WorkflowCheckpointPhase::RaceInFlight { .. } => {
404            return Err(CheckpointError::new(
405                CheckpointErrorKind::Conflict,
406                "workflow checkpoint is ambiguous and cannot be forked safely",
407            ));
408        }
409        _ => {}
410    }
411    Ok(Checkpoint::initial(
412        target,
413        RunId::new(),
414        CHECKPOINT_KIND,
415        CHECKPOINT_SCHEMA_VERSION,
416        serde_json::to_value(state).map_err(|error| {
417            CheckpointError::new(CheckpointErrorKind::InvalidPayload, error.to_string())
418        })?,
419    ))
420}
421
422impl fmt::Debug for WorkflowCheckpoint {
423    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
424        formatter
425            .debug_struct("WorkflowCheckpoint")
426            .field("id", &self.id)
427            .finish_non_exhaustive()
428    }
429}
430
431pub(crate) struct WorkflowCheckpointCursor {
432    handle: WorkflowCheckpoint,
433    envelope: Checkpoint,
434}
435
436impl WorkflowCheckpointCursor {
437    pub(crate) async fn create(
438        handle: &WorkflowCheckpoint,
439        run: &RunContext,
440        state: &WorkflowCheckpointState,
441    ) -> Result<Self, WorkflowError> {
442        let envelope = Checkpoint::initial(
443            handle.id,
444            run.run_id(),
445            CHECKPOINT_KIND,
446            CHECKPOINT_SCHEMA_VERSION,
447            serialize(state)?,
448        );
449        handle.compare_and_swap(&envelope, None).await?;
450        Ok(Self {
451            handle: handle.clone(),
452            envelope,
453        })
454    }
455
456    pub(crate) fn loaded(handle: &WorkflowCheckpoint, envelope: Checkpoint) -> Self {
457        Self {
458            handle: handle.clone(),
459            envelope,
460        }
461    }
462
463    pub(crate) async fn save(
464        &mut self,
465        state: &WorkflowCheckpointState,
466    ) -> Result<(), WorkflowError> {
467        let next = self.envelope.next(serialize(state)?)?;
468        self.handle
469            .compare_and_swap(&next, Some(self.envelope.revision))
470            .await?;
471        self.envelope = next;
472        Ok(())
473    }
474}
475
476fn serialize(state: &WorkflowCheckpointState) -> Result<Value, WorkflowError> {
477    serde_json::to_value(state).map_err(|error| {
478        CheckpointError::new(CheckpointErrorKind::InvalidPayload, error.to_string()).into()
479    })
480}
481
482#[cfg(test)]
483mod tests {
484    use runifold_core::{CheckpointId, RunId, Usage};
485    use serde_json::json;
486
487    use super::*;
488
489    fn in_flight_checkpoint() -> Checkpoint {
490        let state = WorkflowCheckpointState {
491            workflow: "fork-test".into(),
492            workflow_version: 1,
493            layout: vec![StepId::parse("charge").unwrap()],
494            next_index: 0,
495            value: json!({"amount": 42}),
496            outputs: BTreeMap::new(),
497            usage: Usage {
498                tokens: 7,
499                ..Usage::default()
500            },
501            phase: WorkflowCheckpointPhase::StepInFlight {
502                step: StepId::parse("charge").unwrap(),
503            },
504        };
505        Checkpoint::initial(
506            CheckpointId::new(),
507            RunId::new(),
508            CHECKPOINT_KIND,
509            CHECKPOINT_SCHEMA_VERSION,
510            serde_json::to_value(state).unwrap(),
511        )
512    }
513
514    #[test]
515    fn fork_rejects_ambiguous_replay_unless_explicitly_authorized() {
516        let source = in_flight_checkpoint();
517        let error = fork_checkpoint(
518            source.clone(),
519            CheckpointId::new(),
520            WorkflowForkPolicy::RejectAmbiguous,
521        )
522        .unwrap_err();
523        assert_eq!(error.kind, CheckpointErrorKind::Conflict);
524
525        let forked = fork_checkpoint(
526            source,
527            CheckpointId::new(),
528            WorkflowForkPolicy::RetryInterruptedStep,
529        )
530        .unwrap();
531        let revision = decode_revision(forked).unwrap();
532        assert!(matches!(
533            revision.state.phase,
534            WorkflowCheckpointPhase::Ready
535        ));
536        assert_eq!(revision.state.usage.tokens, 7);
537        assert_eq!(revision.state.next_index, 0);
538    }
539}