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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
19#[non_exhaustive]
20pub enum WorkflowResumePolicy {
21 #[default]
23 RejectAmbiguous,
24 RetryInterruptedStep,
26}
27
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30pub struct WorkflowCheckpointHistoryLimit(NonZeroU16);
31
32impl WorkflowCheckpointHistoryLimit {
33 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 pub const fn get(self) -> u16 {
52 self.0.get()
53 }
54}
55
56#[derive(Clone, Debug, PartialEq)]
58pub struct WorkflowCheckpointRevision {
59 pub checkpoint_id: CheckpointId,
61 pub revision: u64,
63 pub run_id: RunId,
65 pub updated_at_ms: u64,
67 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#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
80#[serde(rename_all = "snake_case")]
81#[non_exhaustive]
82pub enum WorkflowForkPolicy {
83 #[default]
85 RejectAmbiguous,
86 RetryInterruptedStep,
88}
89
90#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
92pub struct WorkflowForkCommand {
93 pub fork_checkpoint_id: CheckpointId,
95 pub source_checkpoint_id: CheckpointId,
97 pub source_revision: u64,
99 pub policy: WorkflowForkPolicy,
101}
102
103impl WorkflowForkCommand {
104 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 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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
141pub struct WorkflowLineage {
142 pub parent_checkpoint_id: CheckpointId,
144 pub parent_revision: u64,
146 pub policy: WorkflowForkPolicy,
148}
149
150#[derive(Clone, Copy, Debug, Eq, PartialEq)]
152#[non_exhaustive]
153pub enum WorkflowForkOutcome {
154 Created {
156 checkpoint_id: CheckpointId,
158 },
159 Duplicate {
161 checkpoint_id: CheckpointId,
163 },
164}
165
166#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
168#[serde(tag = "state", rename_all = "snake_case")]
169#[non_exhaustive]
170pub enum WorkflowCheckpointPhase {
171 Ready,
173 StepInFlight {
175 step: StepId,
177 },
178 Waiting {
180 step: StepId,
182 wait: WorkflowWait,
184 },
185 ParallelInFlight {
187 step: StepId,
189 branches: BTreeMap<StepId, ParallelBranchCheckpoint>,
191 },
192 RaceInFlight {
194 step: StepId,
196 branches: BTreeMap<StepId, ParallelBranchCheckpoint>,
198 },
199 Completed {
201 outcome: WorkflowOutcome,
203 },
204}
205
206#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
208#[serde(tag = "state", rename_all = "snake_case")]
209#[non_exhaustive]
210pub enum ParallelBranchCheckpoint {
211 InFlight,
213 Completed {
215 output: Value,
217 },
218 Failed {
220 message: String,
222 },
223 Cancelled,
225}
226
227#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
229pub struct WorkflowCheckpointState {
230 pub workflow: String,
232 pub workflow_version: u32,
234 pub layout: Vec<StepId>,
236 pub next_index: usize,
238 pub value: Value,
240 pub outputs: BTreeMap<StepId, Value>,
242 pub usage: Usage,
244 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#[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 pub fn new(store: Arc<dyn CheckpointStore>) -> Self {
276 Self {
277 id: CheckpointId::new(),
278 backend: WorkflowCheckpointBackend::Local(store),
279 }
280 }
281
282 pub fn existing(id: CheckpointId, store: Arc<dyn CheckpointStore>) -> Self {
284 Self {
285 id,
286 backend: WorkflowCheckpointBackend::Local(store),
287 }
288 }
289
290 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 pub const fn id(&self) -> CheckpointId {
300 self.id
301 }
302
303 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 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}